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:
guisilvagomes 2025-11-05 16:51:33 -03:00
parent f2a9dc7b70
commit 3443e46ca3
47 changed files with 4393 additions and 1999 deletions

View File

@ -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

View File

@ -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
--- ---

View File

@ -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

View File

@ -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) */}
<div className="relative">
<div className={`absolute inset-0 rounded-full animate-pulse-ring ${
resultType === 'success' ? 'bg-blue-100' : 'bg-red-100'
}`}></div>
<div className={`relative rounded-full p-4 sm:p-5 ${
resultType === 'success' ? 'bg-blue-500' : 'bg-red-500'
}`}>
{resultType === 'success' ? (
<CheckCircle2 className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
) : (
<AlertCircle className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
)}
</div>
</div>
{/* Mensagem */}
<div className="space-y-2">
<h3 className={`text-xl sm:text-2xl font-bold ${
resultType === 'success' ? 'text-blue-900' : 'text-red-900'
}`}>
{resultType === 'success' ? 'Consulta Agendada!' : 'Erro no Agendamento'}
</h3>
{resultType === 'success' ? (
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-600">
Sua consulta foi agendada com sucesso. Você receberá uma confirmação por e-mail ou SMS.
</p> </p>
<p className="text-sm text-green-700"> <button
Você receberá uma confirmação por e-mail em breve. onClick={() => {
setShowResultModal(false);
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> </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>
); );
} }

View File

@ -1,12 +1,26 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useEffect, useState, useRef } from "react";
import { MessageCircle, X, Send } from "lucide-react";
interface Message { /**
* Chatbot.tsx
* React + TypeScript component designed for MediConnect.
* - Floating action button (bottom-right)
* - Modal / popup chat window
* - Sends user messages to a backend endpoint (/api/chat) which proxies to an LLM
* - DOES NOT send/collect any sensitive data (PHI). The frontend strips/flags sensitive fields.
* - Configurable persona: "Assistente Virtual do MediConnect"
*
* Integration notes (short):
* - Backend should be a Supabase Edge Function (or Cloudflare Worker) at /api/chat
* - The Edge Function will contain the OpenAI (or other LLM) key and apply the system prompt.
* - Frontend only uses a short-term session id; it never stores patient-identifying data.
*/
type Message = {
id: string; id: string;
role: "user" | "assistant" | "system";
text: string; text: string;
sender: "user" | "bot"; time?: string;
timestamp: Date; };
}
interface ChatbotProps { interface ChatbotProps {
className?: string; className?: string;
@ -17,13 +31,16 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([
{ {
id: "welcome", id: "welcome",
text: "Olá! Sou o assistente virtual do MediConnect. Como posso ajudá-lo hoje?", role: "assistant",
sender: "bot", text: "Olá! 👋 Sou o Assistente Virtual do MediConnect. Estou aqui para ajudá-lo com dúvidas sobre agendamento de consultas, navegação no sistema, funcionalidades e suporte. Como posso ajudar você hoje?",
timestamp: new Date(), time: new Date().toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
}, },
]); ]);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isTyping, setIsTyping] = useState(false); const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => { const scrollToBottom = () => {
@ -34,94 +51,82 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
const quickReplies = [ /**
"Como agendar uma consulta?", * Sanitize user input before sending.
"Como cancelar agendamento?", * This is a basic approach. For production, you might do more thorough checks.
"Esqueci minha senha", */
"Suporte técnico", function sanitizeUserMessage(text: string): string {
]; // Remove potential HTML/script tags (very naive approach)
const cleaned = text.replace(/<[^>]*>/g, "");
const getBotResponse = (userMessage: string): string => { // Truncate if too long
const message = userMessage.toLowerCase(); return cleaned.slice(0, 1000);
// Respostas baseadas em palavras-chave
if (message.includes("agendar") || message.includes("marcar")) {
return "Para agendar uma consulta:\n\n1. Acesse 'Agendar Consulta' no menu\n2. Selecione o médico desejado\n3. Escolha data e horário disponível\n4. Confirme o agendamento\n\nVocê receberá uma confirmação por e-mail!";
} }
if (message.includes("cancelar") || message.includes("remarcar")) { /**
return "Para cancelar ou remarcar uma consulta:\n\n1. Vá em 'Minhas Consultas'\n2. Localize a consulta\n3. Clique em 'Cancelar' ou 'Remarcar'\n\nRecomendamos fazer isso com 24h de antecedência para evitar taxas."; * Send message to backend /api/chat.
* The backend returns { reply: string } in JSON.
*/
async function callChatApi(userText: string): Promise<string> {
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [
{
role: "user",
content: userText,
},
],
}),
});
if (!response.ok) {
console.error("Chat API error:", response.status, response.statusText);
return "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente em alguns instantes.";
} }
if (message.includes("senha") || message.includes("login")) { const data = await response.json();
return "Para recuperar sua senha:\n\n1. Clique em 'Esqueceu a senha?' na tela de login\n2. Insira seu e-mail cadastrado\n3. Você receberá um link para redefinir a senha\n\nSe não receber o e-mail, verifique sua caixa de spam."; return data.reply || "Sem resposta do servidor.";
} catch (error) {
console.error("Erro ao chamar a API de chat:", error);
return "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.";
}
} }
if (message.includes("pagamento") || message.includes("pagar")) { const handleSend = async () => {
return "Aceitamos as seguintes formas de pagamento:\n\n• Cartão de crédito (parcelamento em até 3x)\n• Cartão de débito\n• PIX\n• Boleto bancário\n\nTodos os pagamentos são processados com segurança.";
}
if (message.includes("teleconsulta") || message.includes("online")) {
return "Para realizar uma teleconsulta:\n\n1. Acesse 'Minhas Consultas' no horário agendado\n2. Clique em 'Iniciar Consulta Online'\n3. Permita acesso à câmera e microfone\n\nCertifique-se de ter uma boa conexão de internet!";
}
if (message.includes("histórico") || message.includes("prontuário")) {
return "Seu histórico médico pode ser acessado em:\n\n• 'Meu Perfil' > 'Histórico Médico'\n• 'Minhas Consultas' (consultas anteriores)\n\nVocê pode fazer download de relatórios e receitas quando necessário.";
}
if (
message.includes("suporte") ||
message.includes("ajuda") ||
message.includes("atendimento")
) {
return "Nossa equipe de suporte está disponível:\n\n📞 Telefone: 0800-123-4567\n📧 E-mail: suporte@mediconnect.com.br\n⏰ Horário: Segunda a Sexta, 8h às 18h\n\nVocê também pode acessar nossa Central de Ajuda completa no menu.";
}
if (message.includes("obrigad") || message.includes("valeu")) {
return "Por nada! Estou sempre aqui para ajudar. Se tiver mais dúvidas, é só chamar! 😊";
}
if (
message.includes("oi") ||
message.includes("olá") ||
message.includes("hello")
) {
return "Olá! Como posso ajudá-lo hoje? Você pode perguntar sobre agendamentos, consultas, pagamentos ou qualquer dúvida sobre o MediConnect.";
}
// Resposta padrão
return "Desculpe, não entendi sua pergunta. Você pode:\n\n• Perguntar sobre agendamentos\n• Consultar formas de pagamento\n• Saber sobre teleconsultas\n• Acessar histórico médico\n• Falar com suporte\n\nOu visite nossa Central de Ajuda para mais informações!";
};
const handleSend = () => {
if (!inputValue.trim()) return; if (!inputValue.trim()) return;
// Adiciona mensagem do usuário const sanitized = sanitizeUserMessage(inputValue);
const userMessage: Message = { const userMessage: Message = {
id: Date.now().toString(), id: Date.now().toString(),
text: inputValue, role: "user",
sender: "user", text: sanitized,
timestamp: new Date(), time: new Date().toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
}; };
setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
setInputValue(""); setInputValue("");
setIsLoading(true);
// Simula digitação do bot // Call AI backend
setIsTyping(true); const reply = await callChatApi(sanitized);
setTimeout(() => {
const botResponse: Message = { const assistantMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
text: getBotResponse(inputValue), role: "assistant",
sender: "bot", text: reply,
timestamp: new Date(), time: new Date().toLocaleTimeString("pt-BR", {
}; hour: "2-digit",
setMessages((prev) => [...prev, botResponse]); minute: "2-digit",
setIsTyping(false); }),
}, 1000);
}; };
const handleQuickReply = (reply: string) => { setMessages((prev) => [...prev, assistantMessage]);
setInputValue(reply); setIsLoading(false);
}; };
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
@ -131,34 +136,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>
{message.time && (
<p <p
className={`text-xs mt-1 ${ className={`text-xs mt-1 ${
message.sender === "user" message.role === "user"
? "text-blue-100" ? "text-blue-100"
: "text-gray-400" : "text-gray-400"
}`} }`}
> >
{message.timestamp.toLocaleTimeString("pt-BR", { {message.time}
hour: "2-digit",
minute: "2-digit",
})}
</p> </p>
)}
</div> </div>
</div> </div>
))} ))}
{isTyping && ( {isLoading && (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="bg-white text-gray-800 shadow rounded-lg p-3"> <div className="bg-white text-gray-800 shadow rounded-lg p-3">
<div className="flex gap-1"> <div className="flex gap-1">
@ -260,11 +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>

View 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

View File

@ -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();

View 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>
);
}

View File

@ -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;

View File

@ -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 className="block text-sm font-medium text-gray-700 mb-2">
Data da Consulta *
</label> </label>
<input {medicoId ? (
type="datetime-local" <CalendarPicker
className="w-full border rounded px-2 py-2 text-sm" doctorId={medicoId}
value={dataHora} selectedDate={selectedDate}
onChange={(e) => setDataHora(e.target.value)} onSelectDate={(date) => {
setSelectedDate(date);
setSelectedTime(""); // Resetar horário ao mudar data
}}
/> />
) : (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-gray-500 text-sm">
Selecione um médico primeiro para ver a disponibilidade
</div>
)}
</div>
{/* Seletor de Horários */}
{selectedDate && medicoId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horário * {selectedTime && <span className="text-blue-600 font-semibold">({selectedTime})</span>}
</label>
<AvailableSlotsPicker
doctorId={medicoId}
date={selectedDate}
onSelect={(time) => {
setSelectedTime(time);
}}
/>
</div>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">

View File

@ -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>
); );
}; };

View File

@ -82,3 +82,5 @@ export default function AgendaSection({
</section> </section>
); );
} }

View File

@ -294,3 +294,5 @@ export default function ConsultasSection({
</section> </section>
); );
} }

View File

@ -179,3 +179,5 @@ export default function RelatoriosSection({
</section> </section>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -731,3 +731,5 @@ export function SecretaryPatientList({
</div> </div>
); );
} }

View File

@ -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>
); );
} }

View File

@ -247,3 +247,5 @@ export function AvatarUpload({
</div> </div>
); );
} }

View File

@ -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>
); );
}; };

View File

@ -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);

View File

@ -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 */

View File

@ -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,25 +779,30 @@ const AcompanhamentoPaciente: React.FC = () => {
{/* Paginação Próximas Consultas */} {/* Paginação Próximas Consultas */}
{totalPaginasProximas > 1 && ( {totalPaginasProximas > 1 && (
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
<div className="text-sm text-gray-600 dark:text-gray-400">
Mostrando {((paginaProximas - 1) * consultasPorPagina) + 1} a {Math.min(paginaProximas * consultasPorPagina, todasConsultasProximas.length)} de {todasConsultasProximas.length} consultas
</div>
<div className="flex items-center gap-2">
<button <button
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))} onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
disabled={paginaProximas === 1} disabled={paginaProximas === 1}
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
> >
Anterior Anterior
</button> </button>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-slate-800 rounded-lg">
Página {paginaProximas} de {totalPaginasProximas} Página {paginaProximas} de {totalPaginasProximas}
</span> </span>
<button <button
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))} onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
disabled={paginaProximas === totalPaginasProximas} disabled={paginaProximas === totalPaginasProximas}
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
> >
Próxima Próxima
</button> </button>
</div> </div>
</div>
)} )}
</> </>
)} )}
@ -809,25 +829,30 @@ const AcompanhamentoPaciente: React.FC = () => {
{/* Paginação Consultas Passadas */} {/* Paginação Consultas Passadas */}
{totalPaginasPassadas > 1 && ( {totalPaginasPassadas > 1 && (
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
<div className="text-sm text-gray-600 dark:text-gray-400">
Mostrando {((paginaPassadas - 1) * consultasPorPagina) + 1} a {Math.min(paginaPassadas * consultasPorPagina, todasConsultasPassadas.length)} de {todasConsultasPassadas.length} consultas
</div>
<div className="flex items-center gap-2">
<button <button
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))} onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
disabled={paginaPassadas === 1} disabled={paginaPassadas === 1}
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
> >
Anterior Anterior
</button> </button>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-slate-800 rounded-lg">
Página {paginaPassadas} de {totalPaginasPassadas} Página {paginaPassadas} de {totalPaginasPassadas}
</span> </span>
<button <button
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))} onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
disabled={paginaPassadas === totalPaginasPassadas} disabled={paginaPassadas === totalPaginasPassadas}
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
> >
Próxima Próxima
</button> </button>
</div> </div>
</div>
)} )}
</> </>
)} )}
@ -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;

View File

@ -184,22 +184,25 @@ const AgendamentoPaciente: React.FC = () => {
if (etapa === 4) { if (etapa === 4) {
return ( return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-md p-8 text-center"> <div className="bg-white rounded-lg sm:rounded-xl shadow-md p-6 sm:p-8 text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" /> <CheckCircle className="w-12 h-12 sm:w-16 sm:h-16 text-green-500 mx-auto mb-3 sm:mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-4"> <h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
Consulta Agendada com Sucesso! Consulta Agendada com Sucesso!
</h2> </h2>
<div className="bg-gray-50 rounded-lg p-6 mb-6 text-left"> <div className="bg-gray-50 rounded-lg p-4 sm:p-6 mb-4 sm:mb-6 text-left">
<h3 className="font-semibold mb-3">Detalhes do Agendamento:</h3> <h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
<div className="space-y-2"> Detalhes do Agendamento:
<p> </h3>
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
<p className="break-words">
<strong>Paciente:</strong> {pacienteLogado.nome} <strong>Paciente:</strong> {pacienteLogado.nome}
</p> </p>
<p> <p className="break-words">
<strong>Médico:</strong> {medicoSelecionado?.nome} <strong>Médico:</strong> {medicoSelecionado?.nome}
</p> </p>
<p> <p className="break-words">
<strong>Especialidade:</strong>{" "} <strong>Especialidade:</strong>{" "}
{medicoSelecionado?.especialidade} {medicoSelecionado?.especialidade}
</p> </p>
@ -216,34 +219,39 @@ const AgendamentoPaciente: React.FC = () => {
<strong>Tipo:</strong> {agendamento.tipoConsulta} <strong>Tipo:</strong> {agendamento.tipoConsulta}
</p> </p>
{agendamento.motivoConsulta && ( {agendamento.motivoConsulta && (
<p> <p className="break-words">
<strong>Motivo:</strong> {agendamento.motivoConsulta} <strong>Motivo:</strong> {agendamento.motivoConsulta}
</p> </p>
)} )}
</div> </div>
</div> </div>
<button onClick={resetarAgendamento} className="btn-primary"> <button
onClick={resetarAgendamento}
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
>
Fazer Novo Agendamento Fazer Novo Agendamento
</button> </button>
</div> </div>
</div> </div>
</div>
); );
} }
return ( return (
<div className="max-w-4xl mx-auto"> <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6 lg:space-y-8">
{/* Header com informações do paciente */} {/* Header com informações do paciente */}
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-xl p-6 mb-8 text-white shadow"> <div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-lg sm:rounded-xl p-4 sm:p-6 text-white shadow">
<div className="flex justify-between items-center"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
<div> <div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold"> <h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
Bem-vindo(a), {pacienteLogado.nome}! Bem-vindo(a), {pacienteLogado.nome}!
</h1> </h1>
<p className="opacity-90">Agende sua consulta médica</p> <p className="opacity-90 text-sm sm:text-base">Agende sua consulta médica</p>
</div> </div>
<button <button
onClick={logout} onClick={logout}
className="flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70" className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-white/20 hover:bg-white/30 px-3 sm:px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70 text-sm sm:text-base whitespace-nowrap"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
<span>Sair</span> <span>Sair</span>
@ -254,11 +262,11 @@ const AgendamentoPaciente: React.FC = () => {
{/* As consultas locais serão exibidas na Dashboard do paciente */} {/* As consultas locais serão exibidas na Dashboard do paciente */}
{/* Indicador de Etapas */} {/* Indicador de Etapas */}
<div className="flex items-center justify-center mb-8"> <div className="flex items-center justify-center mb-6 sm:mb-8">
{[1, 2, 3].map((numero) => ( {[1, 2, 3].map((numero) => (
<React.Fragment key={numero}> <React.Fragment key={numero}>
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center ${ className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-sm sm:text-base font-medium ${
etapa >= numero etapa >= numero
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "bg-gray-300 text-gray-600" : "bg-gray-300 text-gray-600"
@ -268,7 +276,7 @@ const AgendamentoPaciente: React.FC = () => {
</div> </div>
{numero < 3 && ( {numero < 3 && (
<div <div
className={`w-16 h-1 ${ className={`w-12 sm:w-16 h-1 ${
etapa > numero ? "bg-blue-600" : "bg-gray-300" etapa > numero ? "bg-blue-600" : "bg-gray-300"
}`} }`}
/> />
@ -277,23 +285,23 @@ const AgendamentoPaciente: React.FC = () => {
))} ))}
</div> </div>
<div className="bg-white rounded-xl shadow border border-gray-200 p-6"> <div className="bg-white rounded-lg sm:rounded-xl shadow border border-gray-200 p-4 sm:p-6">
{/* Etapa 1: Seleção de Médico */} {/* Etapa 1: Seleção de Médico */}
{etapa === 1 && ( {etapa === 1 && (
<div className="space-y-6"> <div className="space-y-4 sm:space-y-6">
<h2 className="text-xl font-semibold flex items-center"> <h2 className="text-lg sm:text-xl font-semibold flex items-center">
<User className="w-5 h-5 mr-2" /> <User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Selecione o Médico Selecione o Médico
</h2> </h2>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Médico/Especialidade Médico/Especialidade
</label> </label>
<select <select
value={agendamento.medicoId} value={agendamento.medicoId}
onChange={(e) => handleMedicoChange(e.target.value)} onChange={(e) => handleMedicoChange(e.target.value)}
className="form-input" className="form-input text-sm sm:text-base"
required required
> >
<option value="">Selecione um médico</option> <option value="">Selecione um médico</option>
@ -306,11 +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>
@ -486,6 +496,7 @@ const AgendamentoPaciente: React.FC = () => {
)} )}
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -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");

View File

@ -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;

View File

@ -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>

View File

@ -60,67 +60,94 @@ const ListaMedicos: React.FC = () => {
}, []); }, []);
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<div className="flex items-center gap-2"> <div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
<Stethoscope className="w-6 h-6 text-indigo-600" /> {/* Cabeçalho Responsivo */}
<h2 className="text-2xl font-bold">Médicos Cadastrados</h2> <div className="flex items-center gap-2 sm:gap-3">
<Stethoscope className="w-5 h-5 sm:w-6 sm:h-6 text-indigo-600 flex-shrink-0" />
<h2 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900">
Médicos Cadastrados
</h2>
</div> </div>
{loading && <div className="text-gray-500">Carregando médicos...</div>} {/* Estados de Loading/Error */}
{loading && (
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
Carregando médicos...
</div>
)}
{!loading && error && ( {!loading && error && (
<div className="flex items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 rounded-lg"> <div className="flex items-start sm:items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg text-sm sm:text-base">
<AlertTriangle className="w-5 h-5" /> <AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5 sm:mt-0" />
<span>{error}</span> <span>{error}</span>
</div> </div>
)} )}
{!loading && !error && medicos.length === 0 && ( {!loading && !error && medicos.length === 0 && (
<div className="text-gray-500">Nenhum médico cadastrado.</div> <div className="text-sm sm:text-base text-gray-500 text-center py-8">
Nenhum médico cadastrado.
</div>
)} )}
{/* Grid de Médicos - Responsivo */}
{!loading && !error && medicos.length > 0 && ( {!loading && !error && medicos.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
{medicos.map((medico) => ( {medicos.map((medico) => (
<article <article
key={medico.id} key={medico.id}
className="bg-white rounded-xl shadow border border-gray-200 p-6 flex flex-col gap-3 hover:shadow-md transition-shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500" className="bg-white rounded-lg sm:rounded-xl shadow-sm hover:shadow-md border border-gray-200 p-4 sm:p-5 lg:p-6 flex flex-col gap-2.5 sm:gap-3 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
tabIndex={0} tabIndex={0}
> >
<header className="flex items-center gap-2"> {/* Header do Card */}
<header className="flex items-center gap-2 sm:gap-3">
{medico.avatar_url ? ( {medico.avatar_url ? (
<img <img
src={medico.avatar_url} src={medico.avatar_url}
alt={medico.nome} alt={medico.nome}
className="h-10 w-10 rounded-full object-cover border" className="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover border flex-shrink-0"
/> />
) : ( ) : (
<div className="flex-shrink-0">
<AvatarInitials name={medico.nome} size={40} /> <AvatarInitials name={medico.nome} size={40} />
</div>
)} )}
<Stethoscope className="w-5 h-5 text-indigo-600" /> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg text-gray-900"> <div className="flex items-center gap-1.5 mb-1">
<Stethoscope className="w-4 h-4 text-indigo-600 flex-shrink-0" />
<h3 className="font-semibold text-sm sm:text-base lg:text-lg text-gray-900 truncate">
{medico.nome} {medico.nome}
</h3> </h3>
</div>
</div>
</header> </header>
<div className="text-sm text-gray-700">
<strong>Especialidade:</strong> {medico.especialidade} {/* Informações do Médico */}
<div className="space-y-1.5 sm:space-y-2">
<div className="text-xs sm:text-sm text-gray-700">
<strong className="font-medium">Especialidade:</strong>{" "}
<span className="break-words">{medico.especialidade}</span>
</div> </div>
<div className="text-sm text-gray-700"> <div className="text-xs sm:text-sm text-gray-700">
<strong>CRM:</strong> {medico.crm} <strong className="font-medium">CRM:</strong> {medico.crm}
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-700"> <div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
<Mail className="w-4 h-4" /> {medico.email} <Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
<span className="break-all">{medico.email}</span>
</div> </div>
{medico.telefone && ( {medico.telefone && (
<div className="flex items-center gap-2 text-sm text-gray-700"> <div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
<Phone className="w-4 h-4" /> {medico.telefone} <Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span>{medico.telefone}</span>
</div> </div>
)} )}
</div>
</article> </article>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
); );
}; };

View File

@ -58,57 +58,80 @@ const ListaPacientes: React.FC = () => {
}, []); }, []);
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2"> <div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
<Users className="w-6 h-6 text-blue-600" /> Pacientes Cadastrados <h2 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3">
<Users className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600 flex-shrink-0" />{" "}
Pacientes Cadastrados
</h2> </h2>
{loading && <div className="text-gray-500">Carregando pacientes...</div>}
{loading && (
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
Carregando pacientes...
</div>
)}
{!loading && error && ( {!loading && error && (
<div className="text-red-600 bg-red-50 border border-red-200 p-3 rounded"> <div className="text-sm sm:text-base text-red-600 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg">
{error} {error}
</div> </div>
)} )}
{!loading && !error && pacientes.length === 0 && ( {!loading && !error && pacientes.length === 0 && (
<div className="text-gray-500">Nenhum paciente cadastrado.</div> <div className="text-sm sm:text-base text-gray-500 text-center py-8">
Nenhum paciente cadastrado.
</div>
)} )}
{!loading && !error && pacientes.length > 0 && ( {!loading && !error && pacientes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
{pacientes.map((paciente, idx) => ( {pacientes.map((paciente, idx) => (
<div <div
key={paciente.id} key={paciente.id}
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${ className={`rounded-lg sm:rounded-xl p-4 sm:p-5 lg:p-6 flex flex-col gap-2 sm:gap-2.5 transition-all border border-gray-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
idx % 2 === 0 ? "bg-white" : "bg-gray-50" idx % 2 === 0 ? "bg-white" : "bg-gray-50"
}`} }`}
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
<div className="flex-shrink-0">
<AvatarInitials name={paciente.full_name} size={40} /> <AvatarInitials name={paciente.full_name} size={40} />
<Users className="w-5 h-5 text-blue-600" /> </div>
<span className="font-semibold text-lg"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0" />
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
{paciente.full_name} {paciente.full_name}
</span> </span>
</div> </div>
<div className="text-sm text-gray-700">
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4" />{" "} <div className="space-y-1.5 sm:space-y-2">
{formatPhone(paciente.phone_mobile)} <div className="text-xs sm:text-sm text-gray-700">
<strong className="font-medium">CPF:</strong> {formatCPF(paciente.cpf)}
</div> </div>
<div className="text-xs text-gray-500"> <div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
Nascimento:{" "} <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 {paciente.birth_date
? new Date(paciente.birth_date).toLocaleDateString() ? new Date(paciente.birth_date).toLocaleDateString()
: "Não informado"} : "Não informado"}
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
); );
}; };

View File

@ -18,43 +18,57 @@ const ListaSecretarias: React.FC = () => {
}, []); }, []);
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2"> <div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
<UserPlus className="w-6 h-6 text-green-600" /> Secretárias Cadastradas <h2 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3">
<UserPlus className="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />{" "}
Secretárias Cadastradas
</h2> </h2>
{secretarias.length === 0 ? ( {secretarias.length === 0 ? (
<div className="text-gray-500">Nenhuma secretária cadastrada.</div> <div className="text-sm sm:text-base text-gray-500 text-center py-8">
Nenhuma secretária cadastrada.
</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
{secretarias.map((sec, idx) => ( {secretarias.map((sec, idx) => (
<div <div
key={idx} key={idx}
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${ className={`rounded-lg sm:rounded-xl p-4 sm:p-5 lg:p-6 flex flex-col gap-2 sm:gap-2.5 transition-all border border-gray-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${
idx % 2 === 0 ? "bg-white" : "bg-gray-50" idx % 2 === 0 ? "bg-white" : "bg-gray-50"
}`} }`}
tabIndex={0} tabIndex={0}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
<UserPlus className="w-5 h-5 text-green-600" /> <UserPlus className="w-4 h-4 sm:w-5 sm:h-5 text-green-600 flex-shrink-0" />
<span className="font-semibold text-lg">{sec.nome}</span> <span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
{sec.nome}
</span>
</div> </div>
<div className="text-sm text-gray-700">
<strong>CPF:</strong> {sec.cpf} <div className="space-y-1.5 sm:space-y-2">
<div className="text-xs sm:text-sm text-gray-700">
<strong className="font-medium">CPF:</strong> {sec.cpf}
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-700"> <div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
<Mail className="w-4 h-4" /> {sec.email} <Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
<span className="break-all">{sec.email}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-700"> <div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
<Phone className="w-4 h-4" /> {sec.telefone} <Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="break-words">{sec.telefone}</span>
</div>
<div className="text-xs sm:text-sm text-gray-500 pt-1">
<strong className="font-medium">Cadastrada em:</strong>{" "}
{new Date(sec.criadoEm).toLocaleString()}
</div> </div>
<div className="text-xs text-gray-500">
Cadastrada em: {new Date(sec.criadoEm).toLocaleString()}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
); );
}; };

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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");
setLoading(false);
return;
}
// Formatar CPF para o formato esperado pela API (XXX.XXX.XXX-XX)
const formattedCpf = formatCPF(userCpf);
// Formatar telefone celular se fornecido
const formattedPhoneMobile = userPhoneMobile ? formatPhone(userPhoneMobile) : "";
// Criar usuário com senha (método obrigatório com CPF)
await userService.createUserWithPassword({ await userService.createUserWithPassword({
email: formUser.email, email: formUser.email.trim(),
password: userPassword, password: userPassword,
full_name: formUser.full_name, full_name: formUser.full_name.trim(),
phone: formUser.phone, phone: formUser.phone || undefined,
phone_mobile: userPhoneMobile, phone_mobile: formattedPhoneMobile || undefined,
cpf: userCpf, cpf: formattedCpf,
role: formUser.role, role: formUser.role,
create_patient_record: createPatientRecord, create_patient_record: createPatientRecord,
}); });
toast.success( toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.` `Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
); );
} else {
// Criar com magic link (padrão)
await userService.createUser(
{ ...formUser, redirect_url: redirectUrl },
false
);
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Magic link enviado para o email.`
);
}
setShowUserModal(false); setShowUserModal(false);
resetFormUser(); resetFormUser();
setUserPassword("");
setUsePassword(false);
setUserCpf("");
setUserPhoneMobile("");
setCreatePatientRecord(false);
loadUsuarios(); loadUsuarios();
} catch (error: any) { } catch (error: unknown) {
console.error("Erro ao criar usuário:", error); console.error("Erro ao criar usuário:", error);
// Mostrar mensagem de erro detalhada // Mostrar mensagem de erro detalhada
const errorMessage = const errorMessage =
error?.response?.data?.message || (error 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,91 +1689,57 @@ 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>
<input
type="text"
required={usePassword}
value={userCpf}
onChange={(e) =>
setUserCpf(e.target.value.replace(/\D/g, ""))
}
maxLength={11}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
placeholder="12345678900"
/>
<p className="text-xs text-gray-500 mt-1">
Apenas números (11 dígitos)
</p>
</div>
{/* Criar registro de paciente */}
<div className="border-t pt-4">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -1740,33 +1750,32 @@ const PainelAdmin: React.FC = () => {
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Criar também registro na tabela de pacientes Criar também registro completo de paciente
</span> </span>
</label> </label>
<p className="text-xs text-gray-500 mt-1 ml-6"> <p className="text-xs text-gray-500 mt-1 ml-6">
Marque se o usuário também for um paciente Recomendado para ter acesso completo aos dados médicos
</p>
</div>
</>
)}
{usePassword && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-xs text-yellow-700">
Campos obrigatórios para criar com senha: Telefone
Celular e CPF
</p> </p>
</div> </div>
)} )}
</div>
</div>
{!usePassword && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-700"> <p className="text-sm font-semibold text-blue-900 mb-1">
Um Magic Link será enviado para o email do usuário para Campos Obrigatórios (Todos os Roles)
ativação da conta </p>
<ul className="text-xs text-blue-700 space-y-0.5 ml-4 list-disc">
<li>Nome Completo</li>
<li>Email (único no sistema)</li>
<li>CPF (formato: XXX.XXX.XXX-XX)</li>
<li>Senha (mínimo 6 caracteres)</li>
<li>Role/Papel</li>
</ul>
<p className="text-xs text-blue-600 mt-2">
Email de confirmação será enviado automaticamente
</p> </p>
</div> </div>
)}
<div className="flex gap-2 justify-end pt-4"> <div className="flex gap-2 justify-end pt-4">
<button <button
@ -1878,20 +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;

View File

@ -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,7 +817,39 @@ const PainelMedico: React.FC = () => {
</div> </div>
); );
const renderAppointments = () => ( // Função para filtrar consultas por data
const filtrarConsultasPorData = (consultas: ConsultaUI[]) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const amanha = new Date(hoje);
amanha.setDate(amanha.getDate() + 1);
const fimDaSemana = new Date(hoje);
fimDaSemana.setDate(fimDaSemana.getDate() + 7);
return consultas.filter((consulta) => {
const dataConsulta = new Date(consulta.dataHora);
dataConsulta.setHours(0, 0, 0, 0);
switch (filtroData) {
case "hoje":
return dataConsulta.getTime() === hoje.getTime();
case "amanha":
return dataConsulta.getTime() === amanha.getTime();
case "semana":
return dataConsulta >= hoje && dataConsulta <= fimDaSemana;
case "todas":
default:
return true;
}
});
};
const renderAppointments = () => {
const consultasFiltradas = filtrarConsultasPorData(consultas);
return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
@ -793,19 +896,26 @@ const PainelMedico: React.FC = () => {
Carregando consultas... Carregando consultas...
</p> </p>
</div> </div>
) : consultas.length === 0 ? ( ) : consultasFiltradas.length === 0 ? (
<p className="text-center py-8 text-gray-600 dark:text-gray-400"> <p className="text-center py-8 text-gray-600 dark:text-gray-400">
Nenhuma consulta encontrada {filtroData === "hoje"
? "Nenhuma consulta agendada para hoje"
: filtroData === "amanha"
? "Nenhuma consulta agendada para amanhã"
: filtroData === "semana"
? "Nenhuma consulta agendada para esta semana"
: "Nenhuma consulta encontrada"}
</p> </p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{consultas.map(renderAppointmentCard)} {consultasFiltradas.map(renderAppointmentCard)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
); );
};
const renderAvailability = () => <DisponibilidadeMedico />; const renderAvailability = () => <DisponibilidadeMedico />;
@ -907,18 +1017,412 @@ const PainelMedico: React.FC = () => {
</div> </div>
); );
// Carregar dados do perfil do médico
const loadDoctorProfile = useCallback(async () => {
if (!doctorId) return;
try {
const doctor = await doctorService.getById(doctorId);
setProfileData({
full_name: doctor.full_name || "",
email: doctor.email || "",
phone: doctor.phone || "",
cpf: doctor.cpf || "",
birth_date: doctor.birth_date || "",
sex: doctor.sex || "",
street: doctor.street || "",
number: doctor.number || "",
complement: doctor.complement || "",
neighborhood: doctor.neighborhood || "",
city: doctor.city || "",
state: doctor.state || "",
cep: doctor.cep || "",
crm: doctor.crm || "",
specialty: doctor.specialty || "",
});
} catch (error) {
console.error("[PainelMedico] Erro ao carregar perfil:", error);
toast.error("Erro ao carregar perfil");
}
}, [doctorId]);
useEffect(() => {
if (doctorId) {
loadDoctorProfile();
}
}, [doctorId, loadDoctorProfile]);
const handleSaveProfile = async () => {
if (!doctorId) return;
try {
await doctorService.update(doctorId, profileData);
toast.success("Perfil atualizado com sucesso!");
setIsEditingProfile(false);
await loadDoctorProfile();
} catch (error) {
console.error("[PainelMedico] Erro ao salvar perfil:", error);
toast.error("Erro ao salvar perfil");
}
};
const handleProfileChange = (field: string, value: string) => {
setProfileData((prev) => ({ ...prev, [field]: value }));
};
const renderSettings = () => ( const renderSettings = () => (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Configurações Meu Perfil
</h1> </h1>
<p className="text-gray-600 dark:text-gray-400">
Gerencie suas informações pessoais e profissionais
</p>
</div>
{!isEditingProfile ? (
<button
onClick={() => setIsEditingProfile(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Edit className="h-4 w-4" />
Editar Perfil
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => {
setIsEditingProfile(false);
loadDoctorProfile();
}}
className="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSaveProfile}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Save className="h-4 w-4" />
Salvar
</button>
</div>
)}
</div>
{/* Avatar Card */}
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Foto de Perfil</h2>
<div className="flex items-center gap-6">
<AvatarUpload
userId={user?.id}
currentAvatarUrl={avatarUrl}
name={profileData.full_name || medicoNome}
color="indigo"
size="xl"
editable={true}
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{profileData.full_name || medicoNome}
</p>
<p className="text-gray-500 dark:text-gray-400">
{profileData.email || user?.email || "Sem email"}
</p>
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
CRM: {profileData.crm || "Não informado"}
</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700"> <div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="border-b border-gray-200 dark:border-slate-700">
<nav className="flex -mb-px">
<button
onClick={() => setProfileTab("personal")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
profileTab === "personal"
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
}`}
>
Dados Pessoais
</button>
<button
onClick={() => setProfileTab("professional")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
profileTab === "professional"
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
}`}
>
Info. Profissionais
</button>
<button
onClick={() => setProfileTab("security")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
profileTab === "security"
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
}`}
>
Segurança
</button>
</nav>
</div>
<div className="p-6"> <div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400"> {/* Tab: Dados Pessoais */}
{profileTab === "personal" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Informações Pessoais
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nome Completo
</label>
<input
type="text"
value={profileData.full_name}
onChange={(e) => handleProfileChange("full_name", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email
</label>
<input
type="email"
value={profileData.email}
onChange={(e) => handleProfileChange("email", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefone
</label>
<input
type="tel"
value={profileData.phone}
onChange={(e) => handleProfileChange("phone", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
CPF
</label>
<input
type="text"
value={profileData.cpf}
disabled
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Data de Nascimento
</label>
<input
type="date"
value={profileData.birth_date}
onChange={(e) => handleProfileChange("birth_date", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Sexo
</label>
<select
value={profileData.sex}
onChange={(e) => handleProfileChange("sex", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
>
<option value="">Selecione</option>
<option value="M">Masculino</option>
<option value="F">Feminino</option>
<option value="O">Outro</option>
</select>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Endereço</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rua
</label>
<input
type="text"
value={profileData.street}
onChange={(e) => handleProfileChange("street", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Número
</label>
<input
type="text"
value={profileData.number}
onChange={(e) => handleProfileChange("number", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Complemento
</label>
<input
type="text"
value={profileData.complement}
onChange={(e) => handleProfileChange("complement", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Bairro
</label>
<input
type="text"
value={profileData.neighborhood}
onChange={(e) => handleProfileChange("neighborhood", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cidade
</label>
<input
type="text"
value={profileData.city}
onChange={(e) => handleProfileChange("city", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Estado
</label>
<input
type="text"
value={profileData.state}
onChange={(e) => handleProfileChange("state", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
maxLength={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
CEP
</label>
<input
type="text"
value={profileData.cep}
onChange={(e) => handleProfileChange("cep", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
</div>
</div>
</div>
)}
{/* Tab: Info Profissionais */}
{profileTab === "professional" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Informações Profissionais
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
CRM
</label>
<input
type="text"
value={profileData.crm}
onChange={(e) => handleProfileChange("crm", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Especialidade
</label>
<input
type="text"
value={profileData.specialty}
onChange={(e) => handleProfileChange("specialty", e.target.value)}
disabled={!isEditingProfile}
className="form-input"
/>
</div>
</div>
</div>
</div>
)}
{/* Tab: Segurança */}
{profileTab === "security" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Alteração de Senha
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Funcionalidade em desenvolvimento Funcionalidade em desenvolvimento
</p> </p>
</div> </div>
</div> </div>
)}
</div>
</div>
</div> </div>
); );
@ -961,10 +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;

View File

@ -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;

View File

@ -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>
); );
} }

View File

@ -1,12 +1,14 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Save } from "lucide-react"; import { Save, ArrowLeft } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { doctorService } from "../services"; import { doctorService } from "../services";
import { AvatarUpload } from "../components/ui/AvatarUpload"; import { AvatarUpload } from "../components/ui/AvatarUpload";
export default function PerfilMedico() { export default function PerfilMedico() {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
@ -43,13 +45,30 @@ export default function PerfilMedico() {
}, [user?.id]); }, [user?.id]);
const loadDoctorData = async () => { const loadDoctorData = async () => {
if (!user?.id) return; if (!user?.id) {
console.error("[PerfilMedico] Sem user.id:", user);
toast.error("Usuário não identificado");
return;
}
try { try {
setLoading(true); setLoading(true);
const doctor = await doctorService.getById(user.id); console.log("[PerfilMedico] Buscando dados do médico...");
// Tentar buscar por user_id primeiro
let doctor = await doctorService.getByUserId(user.id);
// Se não encontrar por user_id, tentar por email
if (!doctor && user.email) {
console.log(
"[PerfilMedico] Médico não encontrado por user_id, tentando por email:",
user.email
);
doctor = await doctorService.getByEmail(user.email);
}
if (doctor) { if (doctor) {
console.log("[PerfilMedico] Dados do médico carregados:", doctor);
setFormData({ setFormData({
full_name: doctor.full_name || "", full_name: doctor.full_name || "",
email: doctor.email || "", email: doctor.email || "",
@ -64,11 +83,28 @@ export default function PerfilMedico() {
education: "", // Doctor type não tem education education: "", // Doctor type não tem education
experience_years: "", // Doctor type não tem experience_years experience_years: "", // Doctor type não tem experience_years
}); });
// Doctor type não tem avatar_url ainda
setAvatarUrl(undefined); setAvatarUrl(undefined);
} else {
console.warn("[PerfilMedico] Médico não encontrado na tabela doctors");
// Usar dados básicos do usuário logado
setFormData({
full_name: user.nome || "",
email: user.email || "",
phone: "",
cpf: "",
birth_date: "",
gender: "",
specialty: "",
crm: "",
crm_state: "",
bio: "",
education: "",
experience_years: "",
});
toast("Preencha seus dados para completar o cadastro", { icon: "" });
} }
} catch (error) { } catch (error) {
console.error("Erro ao carregar dados do médico:", error); console.error("[PerfilMedico] Erro ao carregar dados do médico:", error);
toast.error("Erro ao carregar dados do perfil"); toast.error("Erro ao carregar dados do perfil");
} finally { } finally {
setLoading(false); setLoading(false);
@ -132,37 +168,48 @@ export default function PerfilMedico() {
} }
return ( return (
<div className="min-h-screen bg-gray-50 py-8 px-4"> <div className="min-h-screen bg-gray-50 py-4 sm:py-6 lg:py-8 px-4 sm:px-6">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div> <div className="flex items-start sm:items-center gap-2 sm:gap-3 w-full sm:w-auto">
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1> <button
<p className="text-gray-600"> onClick={() => navigate(-1)}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
title="Voltar"
>
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
<div className="min-w-0 flex-1">
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
Meu Perfil
</h1>
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1">
Gerencie suas informações pessoais e profissionais Gerencie suas informações pessoais e profissionais
</p> </p>
</div> </div>
</div>
{!isEditing ? ( {!isEditing ? (
<button <button
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors" className="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm sm:text-base whitespace-nowrap"
> >
Editar Perfil Editar Perfil
</button> </button>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2 w-full sm:w-auto">
<button <button
onClick={() => { onClick={() => {
setIsEditing(false); setIsEditing(false);
loadDoctorData(); loadDoctorData();
}} }}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" className="flex-1 sm:flex-none px-3 sm:px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm sm:text-base"
> >
Cancelar Cancelar
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2" className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
> >
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
Salvar Salvar
@ -172,9 +219,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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>(

View File

@ -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 ||

View File

@ -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;
} }
/** /**

View File

@ -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

View File

@ -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
*/ */

View File

@ -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",