feat: implementa chatbot AI, gerenciamento de disponibilidade médica, visualização de laudos e melhorias no painel da secretária
- Adiciona chatbot AI com interface responsiva e posicionamento otimizado - Implementa gerenciamento completo de disponibilidade e exceções médicas - Adiciona modal de visualização detalhada de laudos no painel do paciente - Corrige relatórios da secretária para mostrar nomes de médicos - Implementa mensagem de boas-vindas personalizada com nome real - Remove mensagens duplicadas de login - Remove arquivo cleanup-deps.ps1 desnecessário - Atualiza README com todas as novas funcionalidades
This commit is contained in:
parent
f2a9dc7b70
commit
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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(() => {
|
||||||
@ -65,6 +67,9 @@ export default function AgendamentoConsulta({
|
|||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [bookingSuccess, setBookingSuccess] = 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 +92,106 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeamento de string para número (formato da API)
|
||||||
|
const weekdayMap: Record<string, number> = {
|
||||||
|
"sunday": 0,
|
||||||
|
"monday": 1,
|
||||||
|
"tuesday": 2,
|
||||||
|
"wednesday": 3,
|
||||||
|
"thursday": 4,
|
||||||
|
"friday": 5,
|
||||||
|
"saturday": 6
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapeia os dias da semana que o médico atende (converte para número)
|
||||||
|
const availableWeekdays = new Set<number>(
|
||||||
|
availabilities.map((avail) => {
|
||||||
|
// weekday pode vir como número ou string da API
|
||||||
|
let weekdayNum: number;
|
||||||
|
|
||||||
|
if (typeof avail.weekday === 'number') {
|
||||||
|
weekdayNum = avail.weekday;
|
||||||
|
} else if (typeof avail.weekday === 'string') {
|
||||||
|
weekdayNum = weekdayMap[avail.weekday.toLowerCase()] ?? -1;
|
||||||
|
} else {
|
||||||
|
weekdayNum = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[AgendamentoConsulta] Convertendo weekday:", {
|
||||||
|
original: avail.weekday,
|
||||||
|
type: typeof avail.weekday,
|
||||||
|
converted: weekdayNum
|
||||||
|
});
|
||||||
|
return weekdayNum;
|
||||||
|
}).filter(day => day >= 0 && day <= 6) // Remove valores inválidos
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@ -219,16 +324,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,6 +342,22 @@ 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);
|
||||||
@ -253,6 +365,14 @@ export default function AgendamentoConsulta({
|
|||||||
setMotivo("");
|
setMotivo("");
|
||||||
setBookingSuccess(false);
|
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) {
|
||||||
@ -285,84 +405,137 @@ export default function AgendamentoConsulta({
|
|||||||
|
|
||||||
console.log("[AgendamentoConsulta] Consulta criada com sucesso:", appointment);
|
console.log("[AgendamentoConsulta] Consulta criada com sucesso:", appointment);
|
||||||
|
|
||||||
setBookingSuccess(true);
|
// Mostra modal de sucesso
|
||||||
|
setResultType('success');
|
||||||
|
setShowResultModal(true);
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
|
setBookingSuccess(true);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("[AgendamentoConsulta] Erro ao agendar:", error);
|
||||||
|
|
||||||
// Reset form após 3 segundos
|
const errorMessage = error instanceof Error
|
||||||
setTimeout(() => {
|
? error.message
|
||||||
setSelectedMedico(null);
|
: "Erro ao agendar consulta. Tente novamente.";
|
||||||
setSelectedDate(undefined);
|
|
||||||
setSelectedTime("");
|
// Mostra modal de erro
|
||||||
setMotivo("");
|
setResultType('error');
|
||||||
setBookingSuccess(false);
|
setShowResultModal(true);
|
||||||
}, 3000);
|
setBookingError(errorMessage);
|
||||||
} 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."
|
|
||||||
);
|
|
||||||
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 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) */}
|
||||||
</p>
|
<div className="relative">
|
||||||
<p className="text-sm text-green-700">
|
<div className={`absolute inset-0 rounded-full animate-pulse-ring ${
|
||||||
Você receberá uma confirmação por e-mail em breve.
|
resultType === 'success' ? 'bg-blue-100' : 'bg-red-100'
|
||||||
</p>
|
}`}></div>
|
||||||
|
<div className={`relative rounded-full p-4 sm:p-5 ${
|
||||||
|
resultType === 'success' ? 'bg-blue-500' : 'bg-red-500'
|
||||||
|
}`}>
|
||||||
|
{resultType === 'success' ? (
|
||||||
|
<CheckCircle2 className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mensagem */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className={`text-xl sm:text-2xl font-bold ${
|
||||||
|
resultType === 'success' ? 'text-blue-900' : 'text-red-900'
|
||||||
|
}`}>
|
||||||
|
{resultType === 'success' ? 'Consulta Agendada!' : 'Erro no Agendamento'}
|
||||||
|
</h3>
|
||||||
|
{resultType === 'success' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
|
Sua consulta foi agendada com sucesso. Você receberá uma confirmação por e-mail ou SMS.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowResultModal(false);
|
||||||
|
setBookingSuccess(false);
|
||||||
|
setBookingError('');
|
||||||
|
navigate('/acompanhamento', { state: { activeTab: 'consultas' } });
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-800 underline text-sm sm:text-base font-medium"
|
||||||
|
>
|
||||||
|
Ou veja no painel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
|
{bookingError || 'Não foi possível agendar a consulta. Tente novamente.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão OK */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowResultModal(false);
|
||||||
|
setBookingSuccess(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 rounded-lg sm:rounded-xl border 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">
|
||||||
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 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"
|
||||||
>
|
>
|
||||||
<option value="all">Todas as especialidades</option>
|
<option value="all">Todas as especialidades</option>
|
||||||
{specialties.map((esp) => (
|
{specialties.map((esp) => (
|
||||||
@ -378,36 +551,38 @@ 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 rounded-lg sm:rounded-xl border 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" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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">{medico.nome}</h3>
|
||||||
<p className="text-muted-foreground">{medico.especialidade}</p>
|
<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">{medico.email || "-"}</span>
|
||||||
<div className="flex gap-2">
|
<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,67 +592,100 @@ 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 rounded-lg shadow 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">Detalhes do Agendamento</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-sm sm:text-base text-gray-600 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) => (
|
(day, idx) => (
|
||||||
<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>
|
||||||
@ -489,32 +697,47 @@ export default function AgendamentoConsulta({
|
|||||||
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 +747,63 @@ 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><span className="text-blue-600 font-semibold">●</span> Datas disponíveis</p>
|
||||||
<p>🔴 Datas bloqueadas</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 +811,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 +835,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 +845,30 @@ 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 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">Confirmar Agendamento</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-sm sm:text-base text-gray-600">
|
||||||
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 +882,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>
|
||||||
@ -684,3 +908,5 @@ export default function AgendamentoConsulta({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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, "");
|
||||||
|
// Truncate if too long
|
||||||
|
return cleaned.slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const getBotResponse = (userMessage: string): string => {
|
/**
|
||||||
const message = userMessage.toLowerCase();
|
* Send message to backend /api/chat.
|
||||||
|
* The backend returns { reply: string } in JSON.
|
||||||
|
*/
|
||||||
|
async function callChatApi(userText: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: userText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// Respostas baseadas em palavras-chave
|
if (!response.ok) {
|
||||||
if (message.includes("agendar") || message.includes("marcar")) {
|
console.error("Chat API error:", response.status, response.statusText);
|
||||||
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!";
|
return "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente em alguns instantes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.reply || "Sem resposta do servidor.";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao chamar a API de chat:", error);
|
||||||
|
return "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.includes("cancelar") || message.includes("remarcar")) {
|
const handleSend = async () => {
|
||||||
return "Para cancelar ou remarcar uma consulta:\n\n1. Vá em 'Minhas Consultas'\n2. Localize a consulta\n3. Clique em 'Cancelar' ou 'Remarcar'\n\nRecomendamos fazer isso com 24h de antecedência para evitar taxas.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes("senha") || message.includes("login")) {
|
|
||||||
return "Para recuperar sua senha:\n\n1. Clique em 'Esqueceu a senha?' na tela de login\n2. Insira seu e-mail cadastrado\n3. Você receberá um link para redefinir a senha\n\nSe não receber o e-mail, verifique sua caixa de spam.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes("pagamento") || message.includes("pagar")) {
|
|
||||||
return "Aceitamos as seguintes formas de pagamento:\n\n• Cartão de crédito (parcelamento em até 3x)\n• Cartão de débito\n• PIX\n• Boleto bancário\n\nTodos os pagamentos são processados com segurança.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes("teleconsulta") || message.includes("online")) {
|
|
||||||
return "Para realizar uma teleconsulta:\n\n1. Acesse 'Minhas Consultas' no horário agendado\n2. Clique em 'Iniciar Consulta Online'\n3. Permita acesso à câmera e microfone\n\nCertifique-se de ter uma boa conexão de internet!";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes("histórico") || message.includes("prontuário")) {
|
|
||||||
return "Seu histórico médico pode ser acessado em:\n\n• 'Meu Perfil' > 'Histórico Médico'\n• 'Minhas Consultas' (consultas anteriores)\n\nVocê pode fazer download de relatórios e receitas quando necessário.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.includes("suporte") ||
|
|
||||||
message.includes("ajuda") ||
|
|
||||||
message.includes("atendimento")
|
|
||||||
) {
|
|
||||||
return "Nossa equipe de suporte está disponível:\n\n📞 Telefone: 0800-123-4567\n📧 E-mail: suporte@mediconnect.com.br\n⏰ Horário: Segunda a Sexta, 8h às 18h\n\nVocê também pode acessar nossa Central de Ajuda completa no menu.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes("obrigad") || message.includes("valeu")) {
|
|
||||||
return "Por nada! Estou sempre aqui para ajudar. Se tiver mais dúvidas, é só chamar! 😊";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.includes("oi") ||
|
|
||||||
message.includes("olá") ||
|
|
||||||
message.includes("hello")
|
|
||||||
) {
|
|
||||||
return "Olá! Como posso ajudá-lo hoje? Você pode perguntar sobre agendamentos, consultas, pagamentos ou qualquer dúvida sobre o MediConnect.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resposta padrão
|
|
||||||
return "Desculpe, não entendi sua pergunta. Você pode:\n\n• Perguntar sobre agendamentos\n• Consultar formas de pagamento\n• Saber sobre teleconsultas\n• Acessar histórico médico\n• Falar com suporte\n\nOu visite nossa Central de Ajuda para mais informações!";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
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 = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: getBotResponse(inputValue),
|
|
||||||
sender: "bot",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, botResponse]);
|
|
||||||
setIsTyping(false);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickReply = (reply: string) => {
|
const assistantMessage: Message = {
|
||||||
setInputValue(reply);
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: "assistant",
|
||||||
|
text: reply,
|
||||||
|
time: new Date().toLocaleTimeString("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
@ -131,34 +136,71 @@ 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 right-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 +208,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 +232,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>
|
||||||
<p
|
{message.time && (
|
||||||
className={`text-xs mt-1 ${
|
<p
|
||||||
message.sender === "user"
|
className={`text-xs mt-1 ${
|
||||||
? "text-blue-100"
|
message.role === "user"
|
||||||
: "text-gray-400"
|
? "text-blue-100"
|
||||||
}`}
|
: "text-gray-400"
|
||||||
>
|
}`}
|
||||||
{message.timestamp.toLocaleTimeString("pt-BR", {
|
>
|
||||||
hour: "2-digit",
|
{message.time}
|
||||||
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 +315,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,107 @@ 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:", availabilities);
|
||||||
|
|
||||||
|
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);
|
setLoading(false);
|
||||||
|
|
||||||
if (res.slots && Array.isArray(res.slots)) {
|
|
||||||
const times = res.slots.filter((s) => s.available).map((s) => s.time);
|
|
||||||
|
|
||||||
console.log("✅ [AvailableSlotsPicker] Horários disponíveis:", times);
|
|
||||||
setSlots(times);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"❌ [AvailableSlotsPicker] Formato de resposta inválido:",
|
|
||||||
res
|
|
||||||
);
|
|
||||||
toast.error("Erro ao processar horários disponíveis");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} 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();
|
||||||
|
|||||||
343
src/components/agenda/CalendarPicker.tsx
Normal file
343
src/components/agenda/CalendarPicker.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
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,31 @@ 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.getMinutes().toString().padStart(2, '0')}`;
|
||||||
.slice(0, 16);
|
setSelectedDate(dateStr);
|
||||||
setDataHora(local);
|
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 +150,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 +163,29 @@ 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 "presencial" | "telemedicina",
|
||||||
motivo: motivo || undefined,
|
notes: observacoes || undefined,
|
||||||
observacoes: observacoes || undefined,
|
status: status as "requested" | "confirmed" | "checked_in" | "in_progress" | "completed" | "cancelled" | "no_show",
|
||||||
status: status,
|
|
||||||
};
|
};
|
||||||
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 "presencial" | "telemedicina",
|
||||||
motivo: motivo || undefined,
|
notes: observacoes || undefined,
|
||||||
observacoes: 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 +230,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 +248,48 @@ 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>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<input
|
Data da Consulta *
|
||||||
type="datetime-local"
|
</label>
|
||||||
className="w-full border rounded px-2 py-2 text-sm"
|
{medicoId ? (
|
||||||
value={dataHora}
|
<CalendarPicker
|
||||||
onChange={(e) => setDataHora(e.target.value)}
|
doctorId={medicoId}
|
||||||
/>
|
selectedDate={selectedDate}
|
||||||
|
onSelectDate={(date) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setSelectedTime(""); // Resetar horário ao mudar data
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-gray-500 text-sm">
|
||||||
|
Selecione um médico primeiro para ver a disponibilidade
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seletor de Horários */}
|
||||||
|
{selectedDate && medicoId && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Horário * {selectedTime && <span className="text-blue-600 font-semibold">({selectedTime})</span>}
|
||||||
|
</label>
|
||||||
|
<AvailableSlotsPicker
|
||||||
|
doctorId={medicoId}
|
||||||
|
date={selectedDate}
|
||||||
|
onSelect={(time) => {
|
||||||
|
setSelectedTime(time);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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;
|
||||||
@ -39,6 +41,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);
|
||||||
@ -173,6 +177,8 @@ export function SecretaryAppointmentList() {
|
|||||||
appointment_type: "presencial",
|
appointment_type: "presencial",
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
|
setSelectedDate("");
|
||||||
|
setSelectedTime("");
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -536,7 +542,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>
|
||||||
@ -557,7 +563,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>
|
||||||
@ -569,21 +575,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 *
|
||||||
@ -596,7 +587,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>
|
||||||
@ -605,7 +596,48 @@ 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
|
||||||
@ -615,7 +647,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>
|
||||||
@ -708,3 +741,5 @@ export function SecretaryAppointmentList() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,30 @@ 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,
|
||||||
}: {
|
}: {
|
||||||
@ -462,7 +486,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"
|
||||||
/>
|
/>
|
||||||
@ -477,10 +501,11 @@ 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>
|
||||||
@ -497,7 +522,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>
|
||||||
@ -513,7 +538,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"
|
||||||
/>
|
/>
|
||||||
@ -527,7 +552,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>
|
||||||
@ -549,7 +574,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>
|
||||||
@ -571,7 +596,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"
|
||||||
/>
|
/>
|
||||||
@ -587,10 +612,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>
|
||||||
@ -665,3 +691,5 @@ export function SecretaryDoctorList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -19,20 +19,20 @@ import {
|
|||||||
type Weekday,
|
type Weekday,
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
|
|
||||||
// Helper para converter weekday (string em inglês) para texto legível em português
|
// Helper para converter weekday (número 0-6) para texto legível em português
|
||||||
const weekdayToText = (weekday: Weekday | undefined | null): string => {
|
const weekdayToText = (weekday: Weekday | undefined | null): string => {
|
||||||
if (weekday === undefined || weekday === null) {
|
if (weekday === undefined || weekday === null) {
|
||||||
return "Desconhecido";
|
return "Desconhecido";
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekdayMap: Record<Weekday, string> = {
|
const weekdayMap: Record<number, string> = {
|
||||||
sunday: "Domingo",
|
0: "Domingo",
|
||||||
monday: "Segunda-feira",
|
1: "Segunda-feira",
|
||||||
tuesday: "Terça-feira",
|
2: "Terça-feira",
|
||||||
wednesday: "Quarta-feira",
|
3: "Quarta-feira",
|
||||||
thursday: "Quinta-feira",
|
4: "Quinta-feira",
|
||||||
friday: "Sexta-feira",
|
5: "Sexta-feira",
|
||||||
saturday: "Sábado",
|
6: "Sábado",
|
||||||
};
|
};
|
||||||
|
|
||||||
return weekdayMap[weekday] || "Desconhecido";
|
return weekdayMap[weekday] || "Desconhecido";
|
||||||
@ -75,7 +75,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
useState<DoctorAvailability | null>(null);
|
useState<DoctorAvailability | null>(null);
|
||||||
|
|
||||||
// Availability form
|
// Availability form
|
||||||
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
|
const [selectedWeekdays, setSelectedWeekdays] = useState<number[]>([]);
|
||||||
const [startTime, setStartTime] = useState("08:00");
|
const [startTime, setStartTime] = useState("08:00");
|
||||||
const [endTime, setEndTime] = useState("18:00");
|
const [endTime, setEndTime] = useState("18:00");
|
||||||
const [duration, setDuration] = useState(30);
|
const [duration, setDuration] = useState(30);
|
||||||
@ -262,11 +262,11 @@ export function SecretaryDoctorSchedule() {
|
|||||||
slot_minutes: duration,
|
slot_minutes: duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Os dias da semana já estão no formato correto (sunday, monday, etc.)
|
// Os dias da semana já estão no formato correto (0-6)
|
||||||
const promises = selectedWeekdays.map((weekdayStr) => {
|
const promises = selectedWeekdays.map((weekdayNum) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
doctor_id: selectedDoctorId,
|
doctor_id: selectedDoctorId,
|
||||||
weekday: weekdayStr as Weekday,
|
weekday: weekdayNum as Weekday,
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
slot_minutes: duration,
|
slot_minutes: duration,
|
||||||
@ -446,13 +446,13 @@ export function SecretaryDoctorSchedule() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ value: "monday", label: "Segunda" },
|
{ value: 1, label: "Segunda" },
|
||||||
{ value: "tuesday", label: "Terça" },
|
{ value: 2, label: "Terça" },
|
||||||
{ value: "wednesday", label: "Quarta" },
|
{ value: 3, label: "Quarta" },
|
||||||
{ value: "thursday", label: "Quinta" },
|
{ value: 4, label: "Quinta" },
|
||||||
{ value: "friday", label: "Sexta" },
|
{ value: 5, label: "Sexta" },
|
||||||
{ value: "saturday", label: "Sábado" },
|
{ value: 6, label: "Sábado" },
|
||||||
{ value: "sunday", label: "Domingo" },
|
{ value: 0, label: "Domingo" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -475,7 +475,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
<select
|
<select
|
||||||
value={selectedDoctorId}
|
value={selectedDoctorId}
|
||||||
onChange={(e) => setSelectedDoctorId(e.target.value)}
|
onChange={(e) => setSelectedDoctorId(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
>
|
>
|
||||||
{doctors.map((doctor) => (
|
{doctors.map((doctor) => (
|
||||||
<option key={doctor.id} value={doctor.id}>
|
<option key={doctor.id} value={doctor.id}>
|
||||||
@ -811,7 +811,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -822,7 +822,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -835,7 +835,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e) => setDuration(parseInt(e.target.value))}
|
onChange={(e) => setDuration(parseInt(e.target.value))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -874,7 +874,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
<select
|
<select
|
||||||
value={exceptionType}
|
value={exceptionType}
|
||||||
onChange={(e) => setExceptionType(e.target.value)}
|
onChange={(e) => setExceptionType(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
>
|
>
|
||||||
<option value="férias">Férias</option>
|
<option value="férias">Férias</option>
|
||||||
<option value="licença">Licença Médica</option>
|
<option value="licença">Licença Médica</option>
|
||||||
@ -892,7 +892,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={exceptionStartDate}
|
value={exceptionStartDate}
|
||||||
onChange={(e) => setExceptionStartDate(e.target.value)}
|
onChange={(e) => setExceptionStartDate(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -903,7 +903,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={exceptionEndDate}
|
value={exceptionEndDate}
|
||||||
onChange={(e) => setExceptionEndDate(e.target.value)}
|
onChange={(e) => setExceptionEndDate(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -917,7 +917,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
value={exceptionReason}
|
value={exceptionReason}
|
||||||
onChange={(e) => setExceptionReason(e.target.value)}
|
onChange={(e) => setExceptionReason(e.target.value)}
|
||||||
placeholder="Ex: Férias anuais, Conferência médica..."
|
placeholder="Ex: Férias anuais, Conferência médica..."
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -945,7 +945,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={exceptionStartTime}
|
value={exceptionStartTime}
|
||||||
onChange={(e) => setExceptionStartTime(e.target.value)}
|
onChange={(e) => setExceptionStartTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -956,7 +956,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={exceptionEndTime}
|
value={exceptionEndTime}
|
||||||
onChange={(e) => setExceptionEndTime(e.target.value)}
|
onChange={(e) => setExceptionEndTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1009,7 +1009,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={editStartTime}
|
value={editStartTime}
|
||||||
onChange={(e) => setEditStartTime(e.target.value)}
|
onChange={(e) => setEditStartTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -1020,7 +1020,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
type="time"
|
type="time"
|
||||||
value={editEndTime}
|
value={editEndTime}
|
||||||
onChange={(e) => setEditEndTime(e.target.value)}
|
onChange={(e) => setEditEndTime(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1032,7 +1032,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
<select
|
<select
|
||||||
value={editDuration}
|
value={editDuration}
|
||||||
onChange={(e) => setEditDuration(Number(e.target.value))}
|
onChange={(e) => setEditDuration(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
>
|
>
|
||||||
<option value={15}>15 minutos</option>
|
<option value={15}>15 minutos</option>
|
||||||
<option value={20}>20 minutos</option>
|
<option value={20}>20 minutos</option>
|
||||||
@ -1082,3 +1082,5 @@ export function SecretaryDoctorSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -731,3 +731,5 @@ export function SecretaryPatientList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type Report,
|
type Report,
|
||||||
patientService,
|
patientService,
|
||||||
type Patient,
|
type Patient,
|
||||||
|
doctorService,
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
|
|
||||||
export function SecretaryReportList() {
|
export function SecretaryReportList() {
|
||||||
@ -20,6 +21,7 @@ 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 [requestedByNames, setRequestedByNames] = useState<Record<string, string>>({});
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
patient_id: "",
|
patient_id: "",
|
||||||
exam: "",
|
exam: "",
|
||||||
@ -272,12 +274,40 @@ 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))];
|
||||||
|
|
||||||
|
for (const id of uniqueIds) {
|
||||||
|
try {
|
||||||
|
// Tentar buscar como médico primeiro
|
||||||
|
const doctors = await doctorService.list({});
|
||||||
|
const doctor = doctors.find((d) => (d as any).user_id === id || d.id === id);
|
||||||
|
|
||||||
|
if (doctor) {
|
||||||
|
names[id!] = doctor.full_name || "Dr. " + (doctor.full_name || "Médico");
|
||||||
|
} else {
|
||||||
|
// Se não for médico, simplesmente pegar o texto que já está armazenado
|
||||||
|
// pois requested_by pode ser um nome direto
|
||||||
|
names[id!] = id!;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao buscar nome para ID ${id}:`, error);
|
||||||
|
names[id!] = id!; // Manter o valor original em caso de erro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@ -293,6 +323,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");
|
||||||
}
|
}
|
||||||
@ -481,7 +517,9 @@ export function SecretaryReportList() {
|
|||||||
{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">
|
||||||
@ -544,7 +582,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>
|
||||||
@ -566,7 +604,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-green-500"
|
className="form-input"
|
||||||
placeholder="Nome do exame realizado"
|
placeholder="Nome do exame realizado"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -580,7 +618,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>
|
||||||
@ -594,7 +632,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>
|
||||||
@ -693,7 +731,9 @@ 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>
|
||||||
|
|
||||||
@ -785,7 +825,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>
|
||||||
|
|
||||||
@ -805,7 +845,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>
|
||||||
@ -825,7 +865,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>
|
||||||
@ -840,7 +880,7 @@ 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>
|
||||||
@ -855,7 +895,7 @@ export function SecretaryReportList() {
|
|||||||
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"
|
placeholder="Nome do médico solicitante"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -869,7 +909,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>
|
||||||
@ -883,7 +923,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>
|
||||||
@ -914,3 +954,5 @@ export function SecretaryReportList() {
|
|||||||
</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) */
|
||||||
@ -135,6 +166,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 */
|
/* Estilos de Acessibilidade */
|
||||||
|
|||||||
@ -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 => {
|
||||||
@ -80,8 +83,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
const [loadingLaudos, setLoadingLaudos] = 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 pacienteId = user?.id || "";
|
const pacienteId = user?.id || "";
|
||||||
const pacienteNome = user?.nome || "Paciente";
|
const pacienteNome = user?.nome || "Paciente";
|
||||||
@ -92,6 +97,16 @@ 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) {
|
||||||
@ -124,10 +139,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",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -337,10 +352,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}
|
||||||
@ -350,17 +365,17 @@ 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;
|
||||||
@ -377,14 +392,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -392,15 +407,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>
|
||||||
@ -549,18 +564,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
|
||||||
@ -664,28 +679,28 @@ 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")}
|
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"
|
className="form-input"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
@ -764,24 +779,29 @@ 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">
|
||||||
<button
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
|
Mostrando {((paginaProximas - 1) * consultasPorPagina) + 1} a {Math.min(paginaProximas * consultasPorPagina, todasConsultasProximas.length)} de {todasConsultasProximas.length} consultas
|
||||||
disabled={paginaProximas === 1}
|
</div>
|
||||||
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"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<button
|
||||||
Anterior
|
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
|
||||||
</button>
|
disabled={paginaProximas === 1}
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
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"
|
||||||
Página {paginaProximas} de {totalPaginasProximas}
|
>
|
||||||
</span>
|
← Anterior
|
||||||
<button
|
</button>
|
||||||
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
|
<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">
|
||||||
disabled={paginaProximas === totalPaginasProximas}
|
Página {paginaProximas} de {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"
|
</span>
|
||||||
>
|
<button
|
||||||
Próxima
|
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
|
||||||
</button>
|
disabled={paginaProximas === totalPaginasProximas}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||||
|
>
|
||||||
|
Próxima →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -809,24 +829,29 @@ 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">
|
||||||
<button
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
|
Mostrando {((paginaPassadas - 1) * consultasPorPagina) + 1} a {Math.min(paginaPassadas * consultasPorPagina, todasConsultasPassadas.length)} de {todasConsultasPassadas.length} consultas
|
||||||
disabled={paginaPassadas === 1}
|
</div>
|
||||||
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"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<button
|
||||||
Anterior
|
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
|
||||||
</button>
|
disabled={paginaPassadas === 1}
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
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"
|
||||||
Página {paginaPassadas} de {totalPaginasPassadas}
|
>
|
||||||
</span>
|
← Anterior
|
||||||
<button
|
</button>
|
||||||
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
|
<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">
|
||||||
disabled={paginaPassadas === totalPaginasPassadas}
|
Página {paginaPassadas} de {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"
|
</span>
|
||||||
>
|
<button
|
||||||
Próxima
|
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
|
||||||
</button>
|
disabled={paginaPassadas === totalPaginasPassadas}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||||
|
>
|
||||||
|
Próxima →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -942,6 +967,9 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Data
|
Data
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Ações
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
@ -983,6 +1011,19 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
||||||
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
|
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLaudo(laudo);
|
||||||
|
setShowLaudoModal(true);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span>Ver</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -1036,13 +1077,262 @@ 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">
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AcompanhamentoPaciente;
|
export default AcompanhamentoPaciente;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -184,116 +184,124 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
if (etapa === 4) {
|
if (etapa === 4) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||||
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
<div className="max-w-2xl mx-auto">
|
||||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
<div className="bg-white rounded-lg sm:rounded-xl shadow-md p-6 sm:p-8 text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
<CheckCircle className="w-12 h-12 sm:w-16 sm:h-16 text-green-500 mx-auto mb-3 sm:mb-4" />
|
||||||
Consulta Agendada com Sucesso!
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
||||||
</h2>
|
Consulta Agendada com Sucesso!
|
||||||
<div className="bg-gray-50 rounded-lg p-6 mb-6 text-left">
|
</h2>
|
||||||
<h3 className="font-semibold mb-3">Detalhes do Agendamento:</h3>
|
<div className="bg-gray-50 rounded-lg p-4 sm:p-6 mb-4 sm:mb-6 text-left">
|
||||||
<div className="space-y-2">
|
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
||||||
<p>
|
Detalhes do Agendamento:
|
||||||
<strong>Paciente:</strong> {pacienteLogado.nome}
|
</h3>
|
||||||
</p>
|
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
|
||||||
<p>
|
<p className="break-words">
|
||||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Especialidade:</strong>{" "}
|
|
||||||
{medicoSelecionado?.especialidade}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Data:</strong>{" "}
|
|
||||||
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
|
||||||
locale: ptBR,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Horário:</strong> {agendamento.horario}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
|
||||||
</p>
|
|
||||||
{agendamento.motivoConsulta && (
|
|
||||||
<p>
|
|
||||||
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="break-words">
|
||||||
|
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||||
|
</p>
|
||||||
|
<p className="break-words">
|
||||||
|
<strong>Especialidade:</strong>{" "}
|
||||||
|
{medicoSelecionado?.especialidade}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Data:</strong>{" "}
|
||||||
|
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Horário:</strong> {agendamento.horario}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
||||||
|
</p>
|
||||||
|
{agendamento.motivoConsulta && (
|
||||||
|
<p className="break-words">
|
||||||
|
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetarAgendamento}
|
||||||
|
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Fazer Novo Agendamento
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={resetarAgendamento} className="btn-primary">
|
|
||||||
Fazer Novo Agendamento
|
|
||||||
</button>
|
|
||||||
</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">
|
||||||
{/* Header com informações do paciente */}
|
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6 lg:space-y-8">
|
||||||
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-xl p-6 mb-8 text-white shadow">
|
{/* Header com informações do paciente */}
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
||||||
<h1 className="text-2xl font-bold">
|
<div className="min-w-0 flex-1">
|
||||||
Bem-vindo(a), {pacienteLogado.nome}!
|
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
|
||||||
</h1>
|
Bem-vindo(a), {pacienteLogado.nome}!
|
||||||
<p className="opacity-90">Agende sua consulta médica</p>
|
</h1>
|
||||||
</div>
|
<p className="opacity-90 text-sm sm:text-base">Agende sua consulta médica</p>
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
<span>Sair</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
|
||||||
|
|
||||||
{/* Indicador de Etapas */}
|
|
||||||
<div className="flex items-center justify-center mb-8">
|
|
||||||
{[1, 2, 3].map((numero) => (
|
|
||||||
<React.Fragment key={numero}>
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
|
||||||
etapa >= numero
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-gray-300 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{numero}
|
|
||||||
</div>
|
</div>
|
||||||
{numero < 3 && (
|
<button
|
||||||
<div
|
onClick={logout}
|
||||||
className={`w-16 h-1 ${
|
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"
|
||||||
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
>
|
||||||
}`}
|
<LogOut className="w-4 h-4" />
|
||||||
/>
|
<span>Sair</span>
|
||||||
)}
|
</button>
|
||||||
</React.Fragment>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow border border-gray-200 p-6">
|
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
||||||
|
|
||||||
|
{/* Indicador de Etapas */}
|
||||||
|
<div className="flex items-center justify-center mb-6 sm:mb-8">
|
||||||
|
{[1, 2, 3].map((numero) => (
|
||||||
|
<React.Fragment key={numero}>
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-sm sm:text-base font-medium ${
|
||||||
|
etapa >= numero
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-300 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{numero}
|
||||||
|
</div>
|
||||||
|
{numero < 3 && (
|
||||||
|
<div
|
||||||
|
className={`w-12 sm:w-16 h-1 ${
|
||||||
|
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg sm:rounded-xl shadow border border-gray-200 p-4 sm:p-6">
|
||||||
{/* Etapa 1: Seleção de Médico */}
|
{/* Etapa 1: 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 +314,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 +328,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 +355,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 +368,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 +388,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 +406,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 +415,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 +426,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 +444,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>
|
||||||
@ -467,17 +477,17 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</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>
|
||||||
@ -485,6 +495,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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,7 @@ 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 +121,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 +184,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 +253,24 @@ 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">{title}</h3>
|
||||||
<p className="text-sm text-gray-600 mb-4 leading-relaxed">
|
<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,66 +60,93 @@ 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">
|
||||||
</div>
|
<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">
|
||||||
{loading && <div className="text-gray-500">Carregando médicos...</div>}
|
Médicos Cadastrados
|
||||||
|
</h2>
|
||||||
{!loading && error && (
|
|
||||||
<div className="flex items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 rounded-lg">
|
|
||||||
<AlertTriangle className="w-5 h-5" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && medicos.length === 0 && (
|
{/* Estados de Loading/Error */}
|
||||||
<div className="text-gray-500">Nenhum médico cadastrado.</div>
|
{loading && (
|
||||||
)}
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
|
Carregando médicos...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !error && medicos.length > 0 && (
|
{!loading && error && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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">
|
||||||
{medicos.map((medico) => (
|
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5 sm:mt-0" />
|
||||||
<article
|
<span>{error}</span>
|
||||||
key={medico.id}
|
</div>
|
||||||
className="bg-white rounded-xl shadow border border-gray-200 p-6 flex flex-col gap-3 hover:shadow-md transition-shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
)}
|
||||||
tabIndex={0}
|
|
||||||
>
|
{!loading && !error && medicos.length === 0 && (
|
||||||
<header className="flex items-center gap-2">
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
{medico.avatar_url ? (
|
Nenhum médico cadastrado.
|
||||||
<img
|
</div>
|
||||||
src={medico.avatar_url}
|
)}
|
||||||
alt={medico.nome}
|
|
||||||
className="h-10 w-10 rounded-full object-cover border"
|
{/* Grid de Médicos - Responsivo */}
|
||||||
/>
|
{!loading && !error && medicos.length > 0 && (
|
||||||
) : (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
|
||||||
<AvatarInitials name={medico.nome} size={40} />
|
{medicos.map((medico) => (
|
||||||
)}
|
<article
|
||||||
<Stethoscope className="w-5 h-5 text-indigo-600" />
|
key={medico.id}
|
||||||
<h3 className="font-semibold text-lg text-gray-900">
|
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"
|
||||||
{medico.nome}
|
tabIndex={0}
|
||||||
</h3>
|
>
|
||||||
</header>
|
{/* Header do Card */}
|
||||||
<div className="text-sm text-gray-700">
|
<header className="flex items-center gap-2 sm:gap-3">
|
||||||
<strong>Especialidade:</strong> {medico.especialidade}
|
{medico.avatar_url ? (
|
||||||
</div>
|
<img
|
||||||
<div className="text-sm text-gray-700">
|
src={medico.avatar_url}
|
||||||
<strong>CRM:</strong> {medico.crm}
|
alt={medico.nome}
|
||||||
</div>
|
className="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover border flex-shrink-0"
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
/>
|
||||||
<Mail className="w-4 h-4" /> {medico.email}
|
) : (
|
||||||
</div>
|
<div className="flex-shrink-0">
|
||||||
{medico.telefone && (
|
<AvatarInitials name={medico.nome} size={40} />
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
</div>
|
||||||
<Phone className="w-4 h-4" /> {medico.telefone}
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Stethoscope className="w-4 h-4 text-indigo-600 flex-shrink-0" />
|
||||||
|
<h3 className="font-semibold text-sm sm:text-base lg:text-lg text-gray-900 truncate">
|
||||||
|
{medico.nome}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Informações do Médico */}
|
||||||
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-700">
|
||||||
|
<strong className="font-medium">Especialidade:</strong>{" "}
|
||||||
|
<span className="break-words">{medico.especialidade}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-700">
|
||||||
|
<strong className="font-medium">CRM:</strong> {medico.crm}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="break-all">{medico.email}</span>
|
||||||
|
</div>
|
||||||
|
{medico.telefone && (
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
|
<span>{medico.telefone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</article>
|
||||||
</article>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -58,56 +58,79 @@ 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">
|
||||||
</h2>
|
<Users className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600 flex-shrink-0" />{" "}
|
||||||
{loading && <div className="text-gray-500">Carregando pacientes...</div>}
|
Pacientes Cadastrados
|
||||||
{!loading && error && (
|
</h2>
|
||||||
<div className="text-red-600 bg-red-50 border border-red-200 p-3 rounded">
|
|
||||||
{error}
|
{loading && (
|
||||||
</div>
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
)}
|
Carregando pacientes...
|
||||||
{!loading && !error && pacientes.length === 0 && (
|
</div>
|
||||||
<div className="text-gray-500">Nenhum paciente cadastrado.</div>
|
)}
|
||||||
)}
|
|
||||||
{!loading && !error && pacientes.length > 0 && (
|
{!loading && error && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="text-sm sm:text-base text-red-600 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg">
|
||||||
{pacientes.map((paciente, idx) => (
|
{error}
|
||||||
<div
|
</div>
|
||||||
key={paciente.id}
|
)}
|
||||||
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
|
|
||||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
{!loading && !error && pacientes.length === 0 && (
|
||||||
}`}
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
tabIndex={0}
|
Nenhum paciente cadastrado.
|
||||||
>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
)}
|
||||||
<AvatarInitials name={paciente.full_name} size={40} />
|
|
||||||
<Users className="w-5 h-5 text-blue-600" />
|
{!loading && !error && pacientes.length > 0 && (
|
||||||
<span className="font-semibold text-lg">
|
<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">
|
||||||
{paciente.full_name}
|
{pacientes.map((paciente, idx) => (
|
||||||
</span>
|
<div
|
||||||
|
key={paciente.id}
|
||||||
|
className={`rounded-lg sm:rounded-xl p-4 sm:p-5 lg:p-6 flex flex-col gap-2 sm:gap-2.5 transition-all border border-gray-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
|
||||||
|
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||||
|
}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AvatarInitials name={paciente.full_name} size={40} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
|
||||||
|
{paciente.full_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-700">
|
||||||
|
<strong className="font-medium">CPF:</strong> {formatCPF(paciente.cpf)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="break-all">{formatEmail(paciente.email)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
|
<span className="break-words">{formatPhone(paciente.phone_mobile)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 pt-1">
|
||||||
|
<strong className="font-medium">Nascimento:</strong>{" "}
|
||||||
|
{paciente.birth_date
|
||||||
|
? new Date(paciente.birth_date).toLocaleDateString()
|
||||||
|
: "Não informado"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
</div>
|
||||||
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<Phone className="w-4 h-4" />{" "}
|
|
||||||
{formatPhone(paciente.phone_mobile)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Nascimento:{" "}
|
|
||||||
{paciente.birth_date
|
|
||||||
? new Date(paciente.birth_date).toLocaleDateString()
|
|
||||||
: "Não informado"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,42 +18,56 @@ 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">
|
||||||
</h2>
|
<UserPlus className="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />{" "}
|
||||||
{secretarias.length === 0 ? (
|
Secretárias Cadastradas
|
||||||
<div className="text-gray-500">Nenhuma secretária cadastrada.</div>
|
</h2>
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{secretarias.length === 0 ? (
|
||||||
{secretarias.map((sec, idx) => (
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
<div
|
Nenhuma secretária cadastrada.
|
||||||
key={idx}
|
</div>
|
||||||
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${
|
) : (
|
||||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
<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) => (
|
||||||
tabIndex={0}
|
<div
|
||||||
>
|
key={idx}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
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 ${
|
||||||
<UserPlus className="w-5 h-5 text-green-600" />
|
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||||
<span className="font-semibold text-lg">{sec.nome}</span>
|
}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
|
||||||
|
<UserPlus className="w-4 h-4 sm:w-5 sm:h-5 text-green-600 flex-shrink-0" />
|
||||||
|
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
|
||||||
|
{sec.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-700">
|
||||||
|
<strong className="font-medium">CPF:</strong> {sec.cpf}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="break-all">{sec.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
|
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
|
<span className="break-words">{sec.telefone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 pt-1">
|
||||||
|
<strong className="font-medium">Cadastrada em:</strong>{" "}
|
||||||
|
{new Date(sec.criadoEm).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
))}
|
||||||
<strong>CPF:</strong> {sec.cpf}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
</div>
|
||||||
<Mail className="w-4 h-4" /> {sec.email}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<Phone className="w-4 h-4" /> {sec.telefone}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Cadastrada em: {new Date(sec.criadoEm).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,64 @@ 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");
|
||||||
await userService.createUserWithPassword({
|
setLoading(false);
|
||||||
email: formUser.email,
|
return;
|
||||||
password: userPassword,
|
|
||||||
full_name: formUser.full_name,
|
|
||||||
phone: formUser.phone,
|
|
||||||
phone_mobile: userPhoneMobile,
|
|
||||||
cpf: userCpf,
|
|
||||||
role: formUser.role,
|
|
||||||
create_patient_record: createPatientRecord,
|
|
||||||
});
|
|
||||||
toast.success(
|
|
||||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Criar com magic link (padrão)
|
|
||||||
await userService.createUser(
|
|
||||||
{ ...formUser, redirect_url: redirectUrl },
|
|
||||||
false
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
`Usuário ${formUser.full_name} criado com sucesso! Magic link enviado para o email.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Formatar CPF para o formato esperado pela API (XXX.XXX.XXX-XX)
|
||||||
|
const formattedCpf = formatCPF(userCpf);
|
||||||
|
|
||||||
|
// Formatar telefone celular se fornecido
|
||||||
|
const formattedPhoneMobile = userPhoneMobile ? formatPhone(userPhoneMobile) : "";
|
||||||
|
|
||||||
|
// Criar usuário com senha (método obrigatório com CPF)
|
||||||
|
await userService.createUserWithPassword({
|
||||||
|
email: formUser.email.trim(),
|
||||||
|
password: userPassword,
|
||||||
|
full_name: formUser.full_name.trim(),
|
||||||
|
phone: formUser.phone || undefined,
|
||||||
|
phone_mobile: formattedPhoneMobile || undefined,
|
||||||
|
cpf: formattedCpf,
|
||||||
|
role: formUser.role,
|
||||||
|
create_patient_record: createPatientRecord,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||||
|
);
|
||||||
|
|
||||||
setShowUserModal(false);
|
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 as { response?: { data?: { message?: string; error?: string } }; message?: string })
|
||||||
error?.response?.data?.error ||
|
?.response?.data?.message ||
|
||||||
error?.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 +506,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 +698,9 @@ 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 +716,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 +831,35 @@ 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 +1441,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 +1492,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 +1645,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,128 +1689,93 @@ 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">Campos Opcionais</h3>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={usePassword}
|
|
||||||
onChange={(e) => setUsePassword(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Criar com senha (alternativa ao Magic Link)
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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({ ...formUser, phone: formatPhone(e.target.value) })
|
||||||
minLength={6}
|
}
|
||||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
|
maxLength={15}
|
||||||
placeholder="Mínimo 6 caracteres"
|
className="form-input"
|
||||||
/>
|
placeholder="(00) 0000-0000"
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
/>
|
||||||
O usuário precisará confirmar o email antes de fazer
|
</div>
|
||||||
login
|
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Telefone Celular
|
||||||
{/* Telefone Celular (obrigatório quando usa senha) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
Telefone Celular *
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required={usePassword}
|
|
||||||
value={userPhoneMobile}
|
value={userPhoneMobile}
|
||||||
onChange={(e) => setUserPhoneMobile(e.target.value)}
|
onChange={(e) => setUserPhoneMobile(formatPhone(e.target.value))}
|
||||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
|
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 className="flex items-center gap-2 cursor-pointer">
|
||||||
</label>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="text"
|
checked={createPatientRecord}
|
||||||
required={usePassword}
|
onChange={(e) =>
|
||||||
value={userCpf}
|
setCreatePatientRecord(e.target.checked)
|
||||||
onChange={(e) =>
|
}
|
||||||
setUserCpf(e.target.value.replace(/\D/g, ""))
|
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
}
|
/>
|
||||||
maxLength={11}
|
<span className="text-sm font-medium">
|
||||||
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"
|
Criar também registro completo de paciente
|
||||||
placeholder="12345678900"
|
</span>
|
||||||
/>
|
</label>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||||
Apenas números (11 dígitos)
|
Recomendado para ter acesso completo aos dados médicos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Criar registro de paciente */}
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createPatientRecord}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCreatePatientRecord(e.target.checked)
|
|
||||||
}
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Criar também registro na tabela de pacientes
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
|
||||||
Marque se o usuário também for um paciente
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{usePassword && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-yellow-700">
|
|
||||||
⚠️ Campos obrigatórios para criar com senha: Telefone
|
|
||||||
Celular e CPF
|
|
||||||
</p>
|
|
||||||
</div>
|
</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-sm font-semibold text-blue-900 mb-1">
|
||||||
<p className="text-xs text-blue-700">
|
✅ Campos Obrigatórios (Todos os Roles)
|
||||||
ℹ️ Um Magic Link será enviado para o email do usuário para
|
</p>
|
||||||
ativação da conta
|
<ul className="text-xs text-blue-700 space-y-0.5 ml-4 list-disc">
|
||||||
</p>
|
<li>Nome Completo</li>
|
||||||
</div>
|
<li>Email (único no sistema)</li>
|
||||||
)}
|
<li>CPF (formato: XXX.XXX.XXX-XX)</li>
|
||||||
|
<li>Senha (mínimo 6 caracteres)</li>
|
||||||
|
<li>Role/Papel</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">
|
||||||
|
ℹ️ Email de confirmação será enviado automaticamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
<div className="flex gap-2 justify-end pt-4">
|
||||||
<button
|
<button
|
||||||
@ -1878,20 +1887,16 @@ 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({ ...formMedico, cpf: formatCPF(e.target.value) })
|
||||||
setFormMedico({ ...formMedico, cpf: value });
|
}
|
||||||
}}
|
maxLength={14}
|
||||||
maxLength={11}
|
|
||||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
||||||
placeholder="12345678901"
|
placeholder="12345678901"
|
||||||
/>
|
/>
|
||||||
@ -1938,9 +1943,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 +2054,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 +2068,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 +2082,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>
|
||||||
@ -2223,3 +2229,5 @@ const PainelAdmin: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PainelAdmin;
|
export default PainelAdmin;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,75 @@ 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 +187,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 +205,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 +248,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 +268,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingLaudos(false);
|
setLoadingLaudos(false);
|
||||||
}
|
}
|
||||||
}, [medicoId]);
|
}, [doctorId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
@ -746,66 +817,105 @@ const PainelMedico: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAppointments = () => (
|
// Função para filtrar consultas por data
|
||||||
<div className="space-y-6">
|
const filtrarConsultasPorData = (consultas: ConsultaUI[]) => {
|
||||||
<div className="flex items-center justify-between">
|
const hoje = new Date();
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
hoje.setHours(0, 0, 0, 0);
|
||||||
Todas as Consultas
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onClick={handleNovaConsulta}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Nova Consulta
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
const amanha = new Date(hoje);
|
||||||
<div className="flex gap-2">
|
amanha.setDate(amanha.getDate() + 1);
|
||||||
{["hoje", "amanha", "semana", "todas"].map((filtro) => (
|
|
||||||
|
const fimDaSemana = new Date(hoje);
|
||||||
|
fimDaSemana.setDate(fimDaSemana.getDate() + 7);
|
||||||
|
|
||||||
|
return consultas.filter((consulta) => {
|
||||||
|
const dataConsulta = new Date(consulta.dataHora);
|
||||||
|
dataConsulta.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
switch (filtroData) {
|
||||||
|
case "hoje":
|
||||||
|
return dataConsulta.getTime() === hoje.getTime();
|
||||||
|
case "amanha":
|
||||||
|
return dataConsulta.getTime() === amanha.getTime();
|
||||||
|
case "semana":
|
||||||
|
return dataConsulta >= hoje && dataConsulta <= fimDaSemana;
|
||||||
|
case "todas":
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAppointments = () => {
|
||||||
|
const consultasFiltradas = filtrarConsultasPorData(consultas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Todas as Consultas
|
||||||
|
</h1>
|
||||||
<button
|
<button
|
||||||
key={filtro}
|
onClick={handleNovaConsulta}
|
||||||
onClick={() => setFiltroData(filtro)}
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
|
||||||
filtroData === filtro
|
|
||||||
? "bg-indigo-600 text-white"
|
|
||||||
: "bg-white dark:bg-slate-900 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{filtro === "hoje"
|
<Plus className="h-4 w-4" />
|
||||||
? "Hoje"
|
Nova Consulta
|
||||||
: filtro === "amanha"
|
|
||||||
? "Amanhã"
|
|
||||||
: filtro === "semana"
|
|
||||||
? "Esta Semana"
|
|
||||||
: "Todas"}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
{/* Filters */}
|
||||||
<div className="p-6">
|
<div className="flex gap-2">
|
||||||
{loading ? (
|
{["hoje", "amanha", "semana", "todas"].map((filtro) => (
|
||||||
<div className="text-center py-8">
|
<button
|
||||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
key={filtro}
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
onClick={() => setFiltroData(filtro)}
|
||||||
Carregando consultas...
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
||||||
|
filtroData === filtro
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-white dark:bg-slate-900 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filtro === "hoje"
|
||||||
|
? "Hoje"
|
||||||
|
: filtro === "amanha"
|
||||||
|
? "Amanhã"
|
||||||
|
: filtro === "semana"
|
||||||
|
? "Esta Semana"
|
||||||
|
: "Todas"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||||
|
<div className="p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Carregando consultas...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : consultasFiltradas.length === 0 ? (
|
||||||
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||||
|
{filtroData === "hoje"
|
||||||
|
? "Nenhuma consulta agendada para hoje"
|
||||||
|
: filtroData === "amanha"
|
||||||
|
? "Nenhuma consulta agendada para amanhã"
|
||||||
|
: filtroData === "semana"
|
||||||
|
? "Nenhuma consulta agendada para esta semana"
|
||||||
|
: "Nenhuma consulta encontrada"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : consultas.length === 0 ? (
|
<div className="space-y-4">
|
||||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
{consultasFiltradas.map(renderAppointmentCard)}
|
||||||
Nenhuma consulta encontrada
|
</div>
|
||||||
</p>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
{consultas.map(renderAppointmentCard)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
const renderAvailability = () => <DisponibilidadeMedico />;
|
const renderAvailability = () => <DisponibilidadeMedico />;
|
||||||
|
|
||||||
@ -907,17 +1017,411 @@ 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">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
{/* Header */}
|
||||||
Configurações
|
<div className="flex items-center justify-between">
|
||||||
</h1>
|
<div>
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
<div className="p-6">
|
Meu Perfil
|
||||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
</h1>
|
||||||
Funcionalidade em desenvolvimento
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Gerencie suas informações pessoais e profissionais
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!isEditingProfile ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingProfile(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Editar Perfil
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingProfile(false);
|
||||||
|
loadDoctorProfile();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Card */}
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 dark:text-white">Foto de Perfil</h2>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<AvatarUpload
|
||||||
|
userId={user?.id}
|
||||||
|
currentAvatarUrl={avatarUrl}
|
||||||
|
name={profileData.full_name || medicoNome}
|
||||||
|
color="indigo"
|
||||||
|
size="xl"
|
||||||
|
editable={true}
|
||||||
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{profileData.full_name || medicoNome}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{profileData.email || user?.email || "Sem email"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
|
||||||
|
CRM: {profileData.crm || "Não informado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||||
|
<div className="border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<nav className="flex -mb-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setProfileTab("personal")}
|
||||||
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
profileTab === "personal"
|
||||||
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Dados Pessoais
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setProfileTab("professional")}
|
||||||
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
profileTab === "professional"
|
||||||
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Info. Profissionais
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setProfileTab("security")}
|
||||||
|
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
profileTab === "security"
|
||||||
|
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Segurança
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Tab: Dados Pessoais */}
|
||||||
|
{profileTab === "personal" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Informações Pessoais
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Nome Completo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.full_name}
|
||||||
|
onChange={(e) => handleProfileChange("full_name", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={profileData.email}
|
||||||
|
onChange={(e) => handleProfileChange("email", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={profileData.phone}
|
||||||
|
onChange={(e) => handleProfileChange("phone", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
CPF
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.cpf}
|
||||||
|
disabled
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Data de Nascimento
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={profileData.birth_date}
|
||||||
|
onChange={(e) => handleProfileChange("birth_date", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Sexo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={profileData.sex}
|
||||||
|
onChange={(e) => handleProfileChange("sex", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="">Selecione</option>
|
||||||
|
<option value="M">Masculino</option>
|
||||||
|
<option value="F">Feminino</option>
|
||||||
|
<option value="O">Outro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Endereço</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Rua
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.street}
|
||||||
|
onChange={(e) => handleProfileChange("street", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Número
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.number}
|
||||||
|
onChange={(e) => handleProfileChange("number", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Complemento
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.complement}
|
||||||
|
onChange={(e) => handleProfileChange("complement", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Bairro
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.neighborhood}
|
||||||
|
onChange={(e) => handleProfileChange("neighborhood", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Cidade
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.city}
|
||||||
|
onChange={(e) => handleProfileChange("city", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.state}
|
||||||
|
onChange={(e) => handleProfileChange("state", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
CEP
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.cep}
|
||||||
|
onChange={(e) => handleProfileChange("cep", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Info Profissionais */}
|
||||||
|
{profileTab === "professional" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Informações Profissionais
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
CRM
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.crm}
|
||||||
|
onChange={(e) => handleProfileChange("crm", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Especialidade
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.specialty}
|
||||||
|
onChange={(e) => handleProfileChange("specialty", e.target.value)}
|
||||||
|
disabled={!isEditingProfile}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Segurança */}
|
||||||
|
{profileTab === "security" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Alteração de Senha
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Funcionalidade em desenvolvimento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -961,10 +1465,10 @@ 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 +1481,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onSaved={handleSaveConsulta}
|
onSaved={handleSaveConsulta}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
defaultMedicoId={medicoId}
|
defaultMedicoId={doctorId || ""}
|
||||||
lockMedico={false}
|
lockMedico={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1010,7 +1514,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 +1535,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 +1551,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 +1567,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">
|
||||||
@ -1091,3 +1595,5 @@ const PainelMedico: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PainelMedico;
|
export default PainelMedico;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
<div className="max-w-[1400px] mx-auto px-6 py-4">
|
<div className="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">
|
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-gray-900 truncate">
|
||||||
Painel da Secretaria
|
Painel da Secretaria
|
||||||
</h1>
|
</h1>
|
||||||
{user && (
|
{user && (
|
||||||
<p className="text-sm text-gray-600 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 hover:bg-gray-100 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 border-b border-gray-200">
|
<div className="bg-white border-b border-gray-200 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,17 @@ 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 font-medium"
|
? "border-green-600 text-green-600 font-medium"
|
||||||
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
|
: "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,7 +92,7 @@ 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) => {
|
||||||
@ -115,3 +118,5 @@ export default function PainelSecretaria() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)}
|
||||||
Gerencie suas informações pessoais e profissionais
|
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
|
||||||
</p>
|
title="Voltar"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
|
||||||
|
Meu Perfil
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1">
|
||||||
|
Gerencie suas informações pessoais e profissionais
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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,9 @@ 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">Foto de Perfil</h2>
|
||||||
<div className="flex items-center gap-6">
|
<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 +231,12 @@ 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 +245,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 +259,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 +280,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 +304,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 +317,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 +330,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 +342,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 +357,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 +369,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 +405,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 +417,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 +430,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 +446,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 +461,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 +477,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 +508,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 +526,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 +544,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>
|
||||||
|
|
||||||
@ -515,3 +564,5 @@ export default function PerfilMedico() {
|
|||||||
</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,9 @@ 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">Foto de Perfil</h2>
|
||||||
<div className="flex items-center gap-6">
|
<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 +278,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 +305,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 +350,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 +363,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 +378,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 +390,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 +405,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 +417,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 +441,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 +454,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 +469,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 +484,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 +497,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 +511,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 +524,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 +554,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 +579,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 +596,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 +628,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 +646,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 +664,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>
|
||||||
|
|
||||||
@ -680,3 +684,5 @@ export default function PerfilPaciente() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -175,7 +175,19 @@ 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>(
|
||||||
|
|||||||
@ -35,11 +35,12 @@ class AppointmentService {
|
|||||||
|
|
||||||
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("[AppointmentService] Response Data:", JSON.stringify(error?.response?.data, null, 2));
|
||||||
response: error?.response?.data,
|
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,43 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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