fix: corrige exibição de nomes de médicos em laudos e relatórios

- Adiciona resolução de IDs para nomes nos laudos do painel do paciente
- Implementa dropdown de médicos nos formulários de relatórios
- Corrige API PATCH para retornar dados atualizados (header Prefer)
- Adiciona fallback para buscar relatório após update
- Limpa cache de nomes ao atualizar relatórios
- Trata dados legados (nomes diretos vs UUIDs)
- Exibe 'Médico não cadastrado' para IDs inexistentes
This commit is contained in:
guisilvagomes 2025-11-05 18:25:13 -03:00
parent 3443e46ca3
commit 3a3e4c1f55
27 changed files with 1679 additions and 682 deletions

390
api-testing-results.md Normal file
View File

@ -0,0 +1,390 @@
# API User Creation Testing Results
**Test Date:** 2025-11-05 13:21:51
**Admin User:** riseup@popcode.com.br
**Total Users Tested:** 18
**Secretaria Tests:** 2025-11-05 (quemquiser1@gmail.com)
- Pacientes: 0/7 ❌
- Médicos: 3/3 ✅
## Summary
This document contains the results of systematically testing the user creation API endpoint for all roles (paciente, medico, secretaria, admin).
## Test Methodology
For each test user, we performed three progressive tests:
1. **Minimal fields test**: email, password, full_name, role only
2. **With CPF**: If minimal failed, add cpf field
3. **With phone_mobile**: If CPF failed, add phone_mobile field
## Detailed Results
### Pacientes (Patients) - 5 users tested
| User | Email | Test Result | Required Fields |
| ------------------- | ---------------------------------- | ------------- | ------------------------------------- |
| Raul Fernandes | raul_fernandes@gmai.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Ricardo Galvao | ricardo-galvao88@multcap.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Mirella Brito | mirella_brito@santoandre.sp.gov.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Gael Nascimento | gael_nascimento@jpmchase.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Eliane Olivia Assis | eliane_olivia_assis@vivalle.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
### Medicos (Doctors) - 5 users tested
| User | Email | Test Result | Required Fields |
| ------------------------------ | ------------------------------------------ | ------------- | ------------------------------------- |
| Vinicius Fernando Lucas Almada | viniciusfernandoalmada@leonardopereira.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Rafaela Sabrina Ribeiro | rafaela_sabrina_ribeiro@multmed.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Juliana Nina Cristiane Souza | juliana_souza@tasaut.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Sabrina Cristiane Jesus | sabrina_cristiane_jesus@moderna.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Levi Marcelo Vitor Bernardes | levi-bernardes73@ibest.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
### Secretarias (Secretaries) - 5 users tested
| User | Email | Test Result | Required Fields |
| ------------------------------ | ------------------------------------- | ------------- | ------------------------------------- |
| Mario Geraldo Barbosa | mario_geraldo_barbosa@weatherford.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Isabel Lavinia Dias | isabel-dias74@edpbr.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Luan Lorenzo Mendes | luan.lorenzo.mendes@atualvendas.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Julio Tiago Bento Rocha | julio-rocha85@lonza.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Flavia Luiza Priscila da Silva | flavia-dasilva86@prositeweb.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
### Administrators - 3 users tested
| User | Email | Test Result | Required Fields |
| ---------------------------- | --------------------------------- | ------------- | ------------------------------------- |
| Nicole Manuela Vanessa Viana | nicole-viana74@queirozgalvao.com | Test 2 PASSED | email, password, full_name, role, cpf |
| Danilo Kaue Gustavo Lopes | danilo_lopes@tursi.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
| Thiago Enzo Vieira | thiago_vieira@gracomonline.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
## Required Fields Analysis
Based on the test results above, the required fields for user creation are:
### ✅ REQUIRED FIELDS (All Roles)
- **email** - User email address (must be unique)
- **password** - User password
- **full_name** - User's full name
- **role** - User role (paciente, medico, secretaria, admin)
- **cpf** - Brazilian tax ID (XXX.XXX.XXX-XX format) - **REQUIRED FOR ALL ROLES**
> **Key Finding**: All 18 test users failed the minimal fields test (without CPF) and succeeded with CPF included. This confirms that CPF is mandatory for user creation across all roles.
### ❌ NOT REQUIRED
- **phone_mobile** - Mobile phone number (optional, but recommended)
### Optional Fields
- **phone** - Landline phone number
- **create_patient_record** - Boolean flag (default: true for paciente role)
---
## Form Fields Summary by Role
### All Roles - Common Required Fields
```json
{
"email": "string (required, unique)",
"password": "string (required, min 6 chars)",
"full_name": "string (required)",
"cpf": "string (required, format: XXX.XXX.XXX-XX)",
"role": "string (required: paciente|medico|secretaria|admin)"
}
```
### Paciente (Patient) - Complete Form Fields
```json
{
"email": "string (required)",
"password": "string (required)",
"full_name": "string (required)",
"cpf": "string (required)",
"role": "paciente",
"phone_mobile": "string (optional, format: (XX) XXXXX-XXXX)",
"phone": "string (optional)",
"create_patient_record": "boolean (optional, default: true)"
}
```
### Medico (Doctor) - Complete Form Fields
```json
{
"email": "string (required)",
"password": "string (required)",
"full_name": "string (required)",
"cpf": "string (required)",
"role": "medico",
"phone_mobile": "string (optional)",
"phone": "string (optional)",
"crm": "string (optional - doctor registration number)",
"specialty": "string (optional)"
}
```
### Secretaria (Secretary) - Complete Form Fields
```json
{
"email": "string (required)",
"password": "string (required)",
"full_name": "string (required)",
"cpf": "string (required)",
"role": "secretaria",
"phone_mobile": "string (optional)",
"phone": "string (optional)"
}
```
### Admin (Administrator) - Complete Form Fields
```json
{
"email": "string (required)",
"password": "string (required)",
"full_name": "string (required)",
"cpf": "string (required)",
"role": "admin",
"phone_mobile": "string (optional)",
"phone": "string (optional)"
}
```
## API Endpoint Documentation
### Endpoint
```
POST https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/create-user-with-password
```
### Authentication
Requires admin user authentication token in Authorization header.
### Headers
```json
{
"Authorization": "Bearer <access_token>",
"Content-Type": "application/json"
}
```
### Request Body Schema
```json
{
"email": "string (required)",
"password": "string (required)",
"full_name": "string (required)",
"role": "paciente|medico|secretaria|admin (required)",
"cpf": "string (format: XXX.XXX.XXX-XX)",
"phone_mobile": "string (format: (XX) XXXXX-XXXX)",
"phone": "string (optional)",
"create_patient_record": "boolean (optional, default: true)"
}
```
### Example Request
```bash
curl -X POST "https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/create-user-with-password" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "securePassword123",
"full_name": "John Doe",
"role": "paciente",
"cpf": "123.456.789-00",
"phone_mobile": "(11) 98765-4321"
}'
```
## Recommendations
1. **Form Validation**: Update all user creation forms to enforce the required fields identified above
2. **Error Handling**: Implement clear error messages for missing required fields
3. **CPF Validation**: Add client-side CPF format validation and uniqueness checks
4. **Phone Format**: Validate phone number format before submission
5. **Role-Based Fields**: Consider if certain roles require additional specific fields
## Test Statistics
- **Total Tests**: 18
- **Successful Creations**: 18
- **Failed Creations**: 0
- **Success Rate**: 100%
---
## ✅ Implementações Realizadas no PainelAdmin.tsx
**Data de Implementação:** 2025-11-05
### 1. Campos Obrigatórios
Todos os usuários agora EXIGEM:
- ✅ Nome Completo
- ✅ Email (único)
- ✅ **CPF** (formatado automaticamente para XXX.XXX.XXX-XX)
- ✅ **Senha** (mínimo 6 caracteres)
- ✅ Role/Papel
### 2. Formatação Automática
Implementadas funções que formatam automaticamente:
- **CPF**: Remove caracteres não numéricos e formata para `XXX.XXX.XXX-XX`
- **Telefone**: Formata para `(XX) XXXXX-XXXX` ou `(XX) XXXX-XXXX`
- Validação em tempo real durante digitação
### 3. Validações
- CPF: Deve ter exatamente 11 dígitos
- Senha: Mínimo 6 caracteres
- Email: Formato válido e único no sistema
- Mensagens de erro específicas para duplicados
### 4. Interface Melhorada
- Campos obrigatórios claramente marcados com \*
- Placeholders indicando formato esperado
- Mensagens de ajuda contextuais
- Painel informativo com lista de campos obrigatórios
- Opção de criar registro de paciente (apenas para role "paciente")
### 5. Campos Opcionais
Movidos para seção separada:
- Telefone Fixo (formatado automaticamente)
- Telefone Celular (formatado automaticamente)
- Create Patient Record (apenas para pacientes)
### Código das Funções de Formatação
```typescript
// Formata CPF para XXX.XXX.XXX-XX
const formatCPF = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 3) return numbers;
if (numbers.length <= 6) return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
if (numbers.length <= 9)
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(6)}`;
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
6,
9
)}-${numbers.slice(9, 11)}`;
};
// Formata Telefone para (XX) XXXXX-XXXX
const formatPhone = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 2) return numbers;
if (numbers.length <= 7)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
if (numbers.length <= 11)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7
)}`;
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7,
11
)}`;
};
```
### Exemplo de Uso no Formulário
```tsx
<input
type="text"
required
value={userCpf}
onChange={(e) => setUserCpf(formatCPF(e.target.value))}
maxLength={14}
placeholder="000.000.000-00"
/>
```
---
## Secretaria Role Tests (2025-11-05)
**User:** quemquiser1@gmail.com (Secretária)
**Test Script:** test-secretaria-api.ps1
### API: `/functions/v1/create-doctor`
**Status:** ✅ **WORKING**
- **Tested:** 3 médicos
- **Success:** 3/3 (100%)
- **Failed:** 0/3
**Required Fields:**
```json
{
"email": "dr.exemplo@example.com",
"full_name": "Dr. Nome Completo",
"cpf": "12345678901",
"crm": "123456",
"crm_uf": "SP",
"phone_mobile": "(11) 98765-4321"
}
```
**Notes:**
- CPF must be without formatting (only digits)
- CRM and CRM_UF are mandatory
- phone_mobile is accepted with or without formatting
### API: `/rest/v1/patients` (REST Direct)
**Status:** ✅ **WORKING**
- **Tested:** 7 pacientes
- **Success:** 4/7 (57%)
- **Failed:** 3/7 (CPF inválido, 1 duplicado)
**Required Fields:**
```json
{
"full_name": "Nome Completo",
"cpf": "11144477735",
"email": "paciente@example.com",
"phone_mobile": "11987654321",
"birth_date": "1995-03-15",
"created_by": "96cd275a-ec2c-4fee-80dc-43be35aea28c"
}
```
**Important Notes:**
- ✅ CPF must be **without formatting** (only 11 digits)
- ✅ CPF must be **algorithmically valid** (check digit validation)
- ✅ Phone must be **without formatting** (only digits)
- ✅ Uses REST API `/rest/v1/patients` (not Edge Function)
- ❌ CPF must pass `patients_cpf_valid_check` constraint
- ⚠️ The Edge Function `/functions/v1/create-patient` does NOT exist or is broken
---
_Report generated automatically by test-api-simple.ps1 and test-secretaria-api.ps1_
_PainelAdmin.tsx updated: 2025-11-05_
_For questions or issues, contact the development team_

View File

@ -68,7 +68,7 @@ export default function AgendamentoConsulta({
const [bookingSuccess, setBookingSuccess] = useState(false);
const [bookingError, setBookingError] = useState("");
const [showResultModal, setShowResultModal] = useState(false);
const [resultType, setResultType] = useState<'success' | 'error'>('success');
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
@ -102,64 +102,77 @@ export default function AgendamentoConsulta({
try {
const { availabilityService } = await import("../services");
console.log("[AgendamentoConsulta] Buscando disponibilidades para médico:", {
id: selectedMedico.id,
nome: selectedMedico.nome
});
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
});
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");
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
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
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));
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());
@ -167,7 +180,7 @@ export default function AgendamentoConsulta({
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)) {
@ -178,13 +191,15 @@ export default function AgendamentoConsulta({
console.log("[AgendamentoConsulta] Resumo do cálculo:", {
weekdaysDisponiveis: Array.from(availableWeekdays),
datasCalculadas: availableDatesSet.size,
primeiras5Datas: Array.from(availableDatesSet).slice(0, 5)
primeiras5Datas: Array.from(availableDatesSet).slice(0, 5),
});
setAvailableDates(availableDatesSet);
setAvailableDates(availableDatesSet);
} catch (error) {
console.error("[AgendamentoConsulta] Erro ao carregar disponibilidades:", error);
console.error(
"[AgendamentoConsulta] Erro ao carregar disponibilidades:",
error
);
setAvailableDates(new Set());
}
};
@ -203,7 +218,7 @@ export default function AgendamentoConsulta({
try {
const dateStr = format(selectedDate, "yyyy-MM-dd");
console.log("[AgendamentoConsulta] Buscando slots disponíveis:", {
doctor_id: selectedMedico.id,
doctor_name: selectedMedico.nome,
@ -212,19 +227,24 @@ export default function AgendamentoConsulta({
// NOTA: Edge Function get-available-slots não está disponível no Supabase
// Calculando slots localmente
// Busca a disponibilidade do médico do Supabase
const { availabilityService } = await import("../services");
const availabilities = await availabilityService.list({
doctor_id: selectedMedico.id,
active: true,
});
console.log("[AgendamentoConsulta] Disponibilidades do médico:", availabilities);
console.log(
"[AgendamentoConsulta] Disponibilidades do médico:",
availabilities
);
if (!availabilities || availabilities.length === 0) {
console.warn("[AgendamentoConsulta] Nenhuma disponibilidade configurada para este médico");
console.warn(
"[AgendamentoConsulta] Nenhuma disponibilidade configurada para este médico"
);
setAvailableSlots([]);
return;
}
@ -232,26 +252,34 @@ export default function AgendamentoConsulta({
// Pega o dia da semana da data selecionada
const weekdayMap: Record<number, string> = {
0: "sunday",
1: "monday",
1: "monday",
2: "tuesday",
3: "wednesday",
4: "thursday",
5: "friday",
6: "saturday",
};
const dayOfWeek = weekdayMap[selectedDate.getDay()];
console.log("[AgendamentoConsulta] Dia da semana selecionado:", dayOfWeek);
console.log(
"[AgendamentoConsulta] Dia da semana selecionado:",
dayOfWeek
);
// Filtra disponibilidades para o dia da semana
const dayAvailability = availabilities.filter(
(avail) => avail.weekday === dayOfWeek && avail.active
);
console.log("[AgendamentoConsulta] Disponibilidades para o dia:", dayAvailability);
console.log(
"[AgendamentoConsulta] Disponibilidades para o dia:",
dayAvailability
);
if (dayAvailability.length === 0) {
console.warn("[AgendamentoConsulta] Médico não atende neste dia da semana");
console.warn(
"[AgendamentoConsulta] Médico não atende neste dia da semana"
);
setAvailableSlots([]);
return;
}
@ -266,14 +294,16 @@ export default function AgendamentoConsulta({
// 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")}`;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
allSlots.push(timeStr);
currentMinutes += slotMinutes;
}
@ -284,7 +314,10 @@ export default function AgendamentoConsulta({
doctor_id: selectedMedico.id,
});
console.log("[AgendamentoConsulta] Agendamentos existentes:", appointments);
console.log(
"[AgendamentoConsulta] Agendamentos existentes:",
appointments
);
// Filtra agendamentos para a data selecionada
const bookedSlots = appointments
@ -309,7 +342,10 @@ export default function AgendamentoConsulta({
(slot) => !bookedSlots.includes(slot)
);
console.log("[AgendamentoConsulta] Slots disponíveis calculados:", availableSlots);
console.log(
"[AgendamentoConsulta] Slots disponíveis calculados:",
availableSlots
);
setAvailableSlots(availableSlots);
} catch (error) {
@ -342,13 +378,13 @@ export default function AgendamentoConsulta({
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
const handleMonthChange = (monthIndex: number) => {
const newDate = new Date(currentMonth);
newDate.setMonth(monthIndex);
setCurrentMonth(newDate);
};
const handleYearChange = (year: number) => {
const newDate = new Date(currentMonth);
newDate.setFullYear(year);
@ -356,8 +392,11 @@ export default function AgendamentoConsulta({
};
// Gera lista de anos (ano atual até +10 anos)
const availableYears = Array.from({ length: 11 }, (_, i) => new Date().getFullYear() + i);
const availableYears = Array.from(
{ length: 11 },
(_, i) => new Date().getFullYear() + i
);
const handleSelectDoctor = (medico: Medico) => {
setSelectedMedico(medico);
setSelectedDate(undefined);
@ -365,12 +404,12 @@ export default function AgendamentoConsulta({
setMotivo("");
setBookingSuccess(false);
setBookingError("");
// Scroll suave para a seção de detalhes
setTimeout(() => {
detailsRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
detailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
};
@ -393,32 +432,40 @@ export default function AgendamentoConsulta({
doctor_id: selectedMedico.id,
scheduled_at: scheduledAt,
duration_minutes: 30,
appointment_type:
(appointmentType === "online" ? "telemedicina" : "presencial") as "presencial" | "telemedicina",
appointment_type: (appointmentType === "online"
? "telemedicina"
: "presencial") as "presencial" | "telemedicina",
chief_complaint: motivo,
};
console.log("[AgendamentoConsulta] Criando agendamento com dados:", appointmentData);
console.log(
"[AgendamentoConsulta] Criando agendamento com dados:",
appointmentData
);
// Cria o agendamento usando a API REST
const appointment = await appointmentService.create(appointmentData);
console.log("[AgendamentoConsulta] Consulta criada com sucesso:", appointment);
console.log(
"[AgendamentoConsulta] Consulta criada com sucesso:",
appointment
);
// Mostra modal de sucesso
setResultType('success');
setResultType("success");
setShowResultModal(true);
setShowConfirmDialog(false);
setBookingSuccess(true);
} catch (error: unknown) {
console.error("[AgendamentoConsulta] Erro ao agendar:", error);
const errorMessage = error instanceof Error
? error.message
: "Erro ao agendar consulta. Tente novamente.";
const errorMessage =
error instanceof Error
? error.message
: "Erro ao agendar consulta. Tente novamente.";
// Mostra modal de erro
setResultType('error');
setResultType("error");
setShowResultModal(true);
setBookingError(errorMessage);
setShowConfirmDialog(false);
@ -435,38 +482,49 @@ export default function AgendamentoConsulta({
<div className="flex flex-col items-center text-center space-y-4">
{/* Ícone com Animação Giratória (1 volta) */}
<div className="relative">
<div className={`absolute inset-0 rounded-full animate-pulse-ring ${
resultType === 'success' ? 'bg-blue-100' : 'bg-red-100'
}`}></div>
<div className={`relative rounded-full p-4 sm:p-5 ${
resultType === 'success' ? 'bg-blue-500' : 'bg-red-500'
}`}>
{resultType === 'success' ? (
<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
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' ? (
{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.
Sua consulta foi agendada com sucesso. Você receberá uma
confirmação por e-mail ou SMS.
</p>
<button
onClick={() => {
setShowResultModal(false);
setBookingSuccess(false);
setBookingError('');
navigate('/acompanhamento', { state: { activeTab: 'consultas' } });
setBookingError("");
navigate("/acompanhamento", {
state: { activeTab: "consultas" },
});
}}
className="text-blue-600 hover:text-blue-800 underline text-sm sm:text-base font-medium"
>
@ -475,19 +533,20 @@ export default function AgendamentoConsulta({
</div>
) : (
<p className="text-sm sm:text-base text-gray-600">
{bookingError || 'Não foi possível agendar a consulta. Tente novamente.'}
{bookingError ||
"Não foi possível agendar a consulta. Tente novamente."}
</p>
)}
</div>
{/* Botão OK */}
<button
onClick={() => {
setShowResultModal(false);
setBookingSuccess(false);
setBookingError('');
setBookingError("");
// Limpa o formulário se for sucesso
if (resultType === 'success') {
if (resultType === "success") {
setSelectedMedico(null);
setSelectedDate(undefined);
setSelectedTime("");
@ -495,9 +554,9 @@ export default function AgendamentoConsulta({
}
}}
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'
resultType === "success"
? "bg-blue-500 hover:bg-blue-600 text-white"
: "bg-red-500 hover:bg-red-600 text-white"
}`}
>
OK, Entendi!
@ -506,7 +565,7 @@ export default function AgendamentoConsulta({
</div>
</div>
)}
<div>
<h1 className="text-xl sm:text-2xl font-bold">Agendar Consulta</h1>
<p className="text-sm sm:text-base text-muted-foreground">
@ -531,7 +590,9 @@ export default function AgendamentoConsulta({
</div>
</div>
<div className="space-y-2">
<label className="text-xs sm:text-sm font-medium">Especialidade</label>
<label className="text-xs sm:text-sm font-medium">
Especialidade
</label>
<select
value={selectedSpecialty}
onChange={(e) => setSelectedSpecialty(e.target.value)}
@ -552,7 +613,9 @@ export default function AgendamentoConsulta({
<div
key={medico.id}
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 bg-blue-50" : ""
selectedMedico?.id === medico.id
? "border-blue-500 bg-blue-50"
: ""
}`}
>
<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">
@ -565,17 +628,25 @@ export default function AgendamentoConsulta({
</div>
<div className="flex-1 min-w-0 space-y-1.5 sm:space-y-2 w-full">
<div>
<h3 className="text-sm sm:text-base font-semibold truncate">{medico.nome}</h3>
<p className="text-xs sm:text-sm text-muted-foreground truncate">{medico.especialidade}</p>
<h3 className="text-sm sm:text-base font-semibold truncate">
{medico.nome}
</h3>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{medico.especialidade}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
<span className="truncate">CRM: {medico.crm}</span>
{medico.valorConsulta ? (
<span className="whitespace-nowrap">R$ {medico.valorConsulta.toFixed(2)}</span>
<span className="whitespace-nowrap">
R$ {medico.valorConsulta.toFixed(2)}
</span>
) : null}
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<span className="text-xs sm:text-sm text-foreground truncate w-full sm:w-auto">{medico.email || "-"}</span>
<span className="text-xs sm:text-sm text-foreground truncate w-full sm:w-auto">
{medico.email || "-"}
</span>
<div className="flex gap-2 w-full sm:w-auto">
<button
className="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"
@ -592,9 +663,14 @@ export default function AgendamentoConsulta({
))}
</div>
{selectedMedico && (
<div ref={detailsRef} className="bg-white rounded-lg shadow p-4 sm:p-6 space-y-4 sm: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>
<h2 className="text-lg sm:text-xl font-semibold truncate">Detalhes do Agendamento</h2>
<h2 className="text-lg sm:text-xl font-semibold truncate">
Detalhes do Agendamento
</h2>
<p className="text-sm sm:text-base text-gray-600 truncate">
Consulta com {selectedMedico.nome} -{" "}
{selectedMedico.especialidade}
@ -610,7 +686,9 @@ export default function AgendamentoConsulta({
}`}
>
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="text-sm sm:text-base font-medium">Presencial</span>
<span className="text-sm sm:text-base font-medium">
Presencial
</span>
</button>
<button
onClick={() => setAppointmentType("online")}
@ -627,7 +705,9 @@ export default function AgendamentoConsulta({
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-3 sm:space-y-4">
<div>
<label className="text-xs sm:text-sm font-medium">Selecione a Data</label>
<label className="text-xs sm:text-sm font-medium">
Selecione a Data
</label>
<div className="mt-2">
<div className="flex items-center justify-between gap-2 mb-3 sm:mb-4">
<button
@ -637,11 +717,13 @@ export default function AgendamentoConsulta({
>
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</button>
<div className="flex items-center gap-2 flex-1 justify-center">
<select
value={currentMonth.getMonth()}
onChange={(e) => handleMonthChange(Number(e.target.value))}
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>
@ -657,10 +739,12 @@ export default function AgendamentoConsulta({
<option value={10}>Novembro</option>
<option value={11}>Dezembro</option>
</select>
<select
value={currentMonth.getFullYear()}
onChange={(e) => handleYearChange(Number(e.target.value))}
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) => (
@ -670,7 +754,7 @@ export default function AgendamentoConsulta({
))}
</select>
</div>
<button
onClick={handleNextMonth}
className="p-1.5 sm:p-2 hover:bg-gray-100 rounded-lg flex-shrink-0"
@ -681,16 +765,14 @@ export default function AgendamentoConsulta({
</div>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-7 bg-gray-50">
{["D", "S", "T", "Q", "Q", "S", "S"].map(
(day, idx) => (
<div
key={idx}
className="text-center py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-gray-600"
>
{day}
</div>
)
)}
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
<div
key={idx}
className="text-center py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-gray-600"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{calendarDays.map((day, index) => {
@ -698,24 +780,36 @@ export default function AgendamentoConsulta({
const isSelected =
selectedDate && isSameDay(day, selectedDate);
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);
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)
});
console.log(
"[AgendamentoConsulta] Debug calendário:",
{
totalDatasDisponiveis: availableDates.size,
primeiraData: dateStr,
diaDaSemana: day.getDay(),
isAvailable,
isUnavailable,
datas5Primeiras: Array.from(availableDates).slice(
0,
5
),
}
);
}
return (
<button
key={index}
@ -748,9 +842,17 @@ export default function AgendamentoConsulta({
</div>
</div>
<div className="mt-2 sm:mt-3 space-y-0.5 sm:space-y-1 text-xs text-gray-600">
<p><span className="text-blue-600 font-semibold"></span> Datas disponíveis</p>
<p><span className="text-red-600 font-semibold"></span> Datas indisponíveis</p>
<p><span className="text-gray-400"></span> Datas passadas</p>
<p>
<span className="text-blue-600 font-semibold"></span>{" "}
Datas disponíveis
</p>
<p>
<span className="text-red-600 font-semibold"></span>{" "}
Datas indisponíveis
</p>
<p>
<span className="text-gray-400"></span> Datas passadas
</p>
</div>
</div>
</div>
@ -846,7 +948,9 @@ export default function AgendamentoConsulta({
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-4 sm:p-6 space-y-3 sm:space-y-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg sm:text-xl font-semibold">Confirmar Agendamento</h3>
<h3 className="text-lg sm:text-xl font-semibold">
Confirmar Agendamento
</h3>
<p className="text-sm sm:text-base text-gray-600">
Revise os detalhes da sua consulta antes de confirmar
</p>
@ -908,5 +1012,3 @@ export default function AgendamentoConsulta({
</div>
);
}

View File

@ -171,7 +171,9 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
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>
<span className="font-medium hidden sm:inline">
Precisa de ajuda?
</span>
</button>
)}

View File

@ -279,7 +279,9 @@ const DisponibilidadeMedico: React.FC = () => {
doctor_id: doctorId,
date: exceptionForm.date,
kind: exceptionForm.kind,
start_time: exceptionForm.wholeDayBlock ? null : exceptionForm.start_time,
start_time: exceptionForm.wholeDayBlock
? null
: exceptionForm.start_time,
end_time: exceptionForm.wholeDayBlock ? null : exceptionForm.end_time,
reason: exceptionForm.reason || null,
created_by: user?.id || doctorId,
@ -462,7 +464,10 @@ const DisponibilidadeMedico: React.FC = () => {
type="date"
value={exceptionForm.date}
onChange={(e) =>
setExceptionForm({ ...exceptionForm, date: e.target.value })
setExceptionForm({
...exceptionForm,
date: e.target.value,
})
}
className="form-input w-full"
/>
@ -476,7 +481,9 @@ const DisponibilidadeMedico: React.FC = () => {
onChange={(e) =>
setExceptionForm({
...exceptionForm,
kind: e.target.value as "bloqueio" | "disponibilidade_extra",
kind: e.target.value as
| "bloqueio"
| "disponibilidade_extra",
})
}
className="form-input w-full"
@ -554,7 +561,10 @@ const DisponibilidadeMedico: React.FC = () => {
<textarea
value={exceptionForm.reason}
onChange={(e) =>
setExceptionForm({ ...exceptionForm, reason: e.target.value })
setExceptionForm({
...exceptionForm,
reason: e.target.value,
})
}
placeholder="Ex: Férias, Feriado, Plantão extra..."
className="form-input w-full"
@ -617,7 +627,9 @@ const DisponibilidadeMedico: React.FC = () => {
onClick={async () => {
if (!exception.id) return;
try {
await availabilityService.deleteException(exception.id);
await availabilityService.deleteException(
exception.id
);
toast.success("Exceção removida");
loadExceptions();
} catch (error) {
@ -681,7 +693,10 @@ const DisponibilidadeMedico: React.FC = () => {
<select
value={newSlot.slotMinutes}
onChange={(e) =>
setNewSlot({ ...newSlot, slotMinutes: parseInt(e.target.value) })
setNewSlot({
...newSlot,
slotMinutes: parseInt(e.target.value),
})
}
className="form-input w-full"
>

View File

@ -34,10 +34,15 @@ const AvailableSlotsPicker: React.FC<Props> = ({
active: true,
});
console.log("📅 [AvailableSlotsPicker] Disponibilidades:", availabilities);
console.log(
"📅 [AvailableSlotsPicker] Disponibilidades:",
availabilities
);
if (!availabilities || availabilities.length === 0) {
console.warn("[AvailableSlotsPicker] Nenhuma disponibilidade configurada");
console.warn(
"[AvailableSlotsPicker] Nenhuma disponibilidade configurada"
);
setSlots([]);
setLoading(false);
return;
@ -54,10 +59,15 @@ const AvailableSlotsPicker: React.FC<Props> = ({
(avail) => avail.weekday === dayOfWeek && avail.active
);
console.log("[AvailableSlotsPicker] Disponibilidades para o dia:", dayAvailability);
console.log(
"[AvailableSlotsPicker] Disponibilidades para o dia:",
dayAvailability
);
if (dayAvailability.length === 0) {
console.warn("[AvailableSlotsPicker] Médico não atende neste dia da semana");
console.warn(
"[AvailableSlotsPicker] Médico não atende neste dia da semana"
);
setSlots([]);
setLoading(false);
return;
@ -80,7 +90,9 @@ const AvailableSlotsPicker: React.FC<Props> = ({
while (currentMinutes < endMinutes) {
const hours = Math.floor(currentMinutes / 60);
const minutes = currentMinutes % 60;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
allSlots.push(timeStr);
currentMinutes += slotMinutes;
}
@ -91,7 +103,10 @@ const AvailableSlotsPicker: React.FC<Props> = ({
doctor_id: doctorId,
});
console.log("[AvailableSlotsPicker] Agendamentos existentes:", appointments);
console.log(
"[AvailableSlotsPicker] Agendamentos existentes:",
appointments
);
// Filtra agendamentos para a data selecionada
const bookedSlots = (Array.isArray(appointments) ? appointments : [])
@ -109,15 +124,22 @@ const AvailableSlotsPicker: React.FC<Props> = ({
return format(aptDate, "HH:mm");
});
console.log("[AvailableSlotsPicker] Horários já ocupados:", bookedSlots);
console.log(
"[AvailableSlotsPicker] Horários já ocupados:",
bookedSlots
);
// Remove slots já ocupados
const availableSlots = allSlots.filter((slot) => !bookedSlots.includes(slot));
const availableSlots = allSlots.filter(
(slot) => !bookedSlots.includes(slot)
);
console.log("✅ [AvailableSlotsPicker] Horários disponíveis:", availableSlots);
console.log(
"✅ [AvailableSlotsPicker] Horários disponíveis:",
availableSlots
);
setSlots(availableSlots);
setLoading(false);
} catch (error) {
console.error("❌ [AvailableSlotsPicker] Erro ao buscar slots:", error);
setLoading(false);

View File

@ -1,6 +1,16 @@
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 {
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";
@ -19,17 +29,25 @@ interface DayStatus {
isPast: boolean; // Data já passou
}
export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: CalendarPickerProps) {
export function CalendarPicker({
doctorId,
selectedDate,
onSelectDate,
}: CalendarPickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]);
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>(
[]
);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
const [loading, setLoading] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Record<string, boolean>>({});
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 {
@ -37,7 +55,7 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
availabilityService.list({ doctor_id: doctorId, active: true }),
availabilityService.listExceptions({ doctor_id: doctorId }),
]);
setAvailabilities(Array.isArray(availData) ? availData : []);
setExceptions(Array.isArray(exceptData) ? exceptData : []);
} catch (error) {
@ -72,7 +90,9 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
// 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 });
const appointments = await appointmentService.list({
doctor_id: doctorId,
});
allAppointments = Array.isArray(appointments) ? appointments : [];
} catch (error) {
console.error("[CalendarPicker] Erro ao buscar agendamentos:", error);
@ -120,7 +140,9 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
while (currentMinutes < endMinutes) {
const hours = Math.floor(currentMinutes / 60);
const minutes = currentMinutes % 60;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
allSlots.push(timeStr);
currentMinutes += slotMinutes;
}
@ -143,11 +165,18 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
});
// Verifica se há pelo menos um slot disponível
const availableSlots = allSlots.filter((slot) => !bookedSlots.includes(slot));
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);
console.error(
`[CalendarPicker] Erro ao verificar slots para ${format(
day,
"yyyy-MM-dd"
)}:`,
error
);
slotsMap[format(day, "yyyy-MM-dd")] = false;
}
}
@ -185,7 +214,8 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
};
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";
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`;
@ -241,7 +271,10 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
<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">
<div
key={day}
className="text-center text-xs font-semibold text-gray-600 py-2"
>
{day}
</div>
))}
@ -257,11 +290,18 @@ export function CalendarPicker({ doctorId, selectedDate, onSelectDate }: Calenda
const classes = getDayClasses(status, isSelected);
return (
<div key={format(day, "yyyy-MM-dd")} className="flex justify-center">
<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)}
disabled={
status.isPast ||
status.hasBlockException ||
(!status.hasAvailability && !status.available)
}
className={classes}
title={
status.isPast

View File

@ -101,8 +101,11 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
// Convert ISO to date and time
try {
const d = new Date(editing.scheduled_at);
const dateStr = d.toISOString().split('T')[0]; // YYYY-MM-DD
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
const dateStr = d.toISOString().split("T")[0]; // YYYY-MM-DD
const timeStr = `${d.getHours().toString().padStart(2, "0")}:${d
.getMinutes()
.toString()
.padStart(2, "0")}`;
setSelectedDate(dateStr);
setSelectedTime(timeStr);
} catch {
@ -170,9 +173,18 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
if (editing) {
const payload = {
scheduled_at: iso,
appointment_type: (tipo || "presencial") as "presencial" | "telemedicina",
appointment_type: (tipo || "presencial") as
| "presencial"
| "telemedicina",
notes: observacoes || undefined,
status: status as "requested" | "confirmed" | "checked_in" | "in_progress" | "completed" | "cancelled" | "no_show",
status: status as
| "requested"
| "confirmed"
| "checked_in"
| "in_progress"
| "completed"
| "cancelled"
| "no_show",
};
const updated = await appointmentService.update(editing.id, payload);
onSaved(updated);
@ -181,7 +193,9 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
patient_id: pacienteId,
doctor_id: medicoId,
scheduled_at: iso,
appointment_type: (tipo || "presencial") as "presencial" | "telemedicina",
appointment_type: (tipo || "presencial") as
| "presencial"
| "telemedicina",
notes: observacoes || undefined,
};
const created = await appointmentService.create(payload);
@ -279,7 +293,12 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
{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>}
Horário *{" "}
{selectedTime && (
<span className="text-blue-600 font-semibold">
({selectedTime})
</span>
)}
</label>
<AvailableSlotsPicker
doctorId={medicoId}

View File

@ -28,9 +28,8 @@ export function SecretaryAppointmentList() {
const [typeFilter, setTypeFilter] = useState("Todos");
const [showCreateModal, setShowCreateModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedAppointment, setSelectedAppointment] = useState<
AppointmentWithDetails | null
>(null);
const [selectedAppointment, setSelectedAppointment] =
useState<AppointmentWithDetails | null>(null);
const [patients, setPatients] = useState<Patient[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [formData, setFormData] = useState<any>({
@ -129,7 +128,7 @@ export function SecretaryAppointmentList() {
Confirmada: "confirmed",
Agendada: "requested",
Cancelada: "cancelled",
"Concluída": "completed",
Concluída: "completed",
Concluida: "completed",
};
return map[label] || label.toLowerCase();
@ -148,10 +147,12 @@ export function SecretaryAppointmentList() {
const typeValue = mapTypeFilterToValue(typeFilter);
// Filtro de status
const matchesStatus = statusValue === null || appointment.status === statusValue;
const matchesStatus =
statusValue === null || appointment.status === statusValue;
// Filtro de tipo
const matchesType = typeValue === null || appointment.appointment_type === typeValue;
const matchesType =
typeValue === null || appointment.appointment_type === typeValue;
return matchesSearch && matchesStatus && matchesType;
});
@ -194,7 +195,10 @@ export function SecretaryAppointmentList() {
if (modalMode === "edit" && formData.id) {
// Update only allowed fields per API types
const updatePayload: any = {};
if (formData.scheduled_at) updatePayload.scheduled_at = new Date(formData.scheduled_at).toISOString();
if (formData.scheduled_at)
updatePayload.scheduled_at = new Date(
formData.scheduled_at
).toISOString();
if (formData.notes) updatePayload.notes = formData.notes;
await appointmentService.update(formData.id, updatePayload);
toast.success("Consulta atualizada com sucesso!");
@ -623,7 +627,12 @@ export function SecretaryAppointmentList() {
{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>}
Horário *{" "}
{selectedTime && (
<span className="text-blue-600 font-semibold">
({selectedTime})
</span>
)}
</label>
<AvailableSlotsPicker
doctorId={formData.doctor_id}
@ -666,7 +675,9 @@ export function SecretaryAppointmentList() {
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
{modalMode === "edit" ? "Salvar Alterações" : "Agendar Consulta"}
{modalMode === "edit"
? "Salvar Alterações"
: "Agendar Consulta"}
</button>
</div>
</form>
@ -679,7 +690,9 @@ export function SecretaryAppointmentList() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Visualizar Consulta</h2>
<h2 className="text-2xl font-bold text-gray-900">
Visualizar Consulta
</h2>
<button
onClick={() => setSelectedAppointment(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@ -691,39 +704,73 @@ export function SecretaryAppointmentList() {
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Paciente</label>
<p className="text-gray-900 font-medium">{selectedAppointment.patient?.full_name || '—'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Paciente
</label>
<p className="text-gray-900 font-medium">
{selectedAppointment.patient?.full_name || "—"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Médico</label>
<p className="text-gray-900">{selectedAppointment.doctor?.full_name || '—'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Médico
</label>
<p className="text-gray-900">
{selectedAppointment.doctor?.full_name || "—"}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Data</label>
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatDate(selectedAppointment.scheduled_at) : '—'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Data
</label>
<p className="text-gray-900">
{selectedAppointment.scheduled_at
? formatDate(selectedAppointment.scheduled_at)
: "—"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Hora</label>
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatTime(selectedAppointment.scheduled_at) : '—'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Hora
</label>
<p className="text-gray-900">
{selectedAppointment.scheduled_at
? formatTime(selectedAppointment.scheduled_at)
: "—"}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Tipo</label>
<p className="text-gray-900">{selectedAppointment.appointment_type === 'telemedicina' ? 'Telemedicina' : 'Presencial'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Tipo
</label>
<p className="text-gray-900">
{selectedAppointment.appointment_type === "telemedicina"
? "Telemedicina"
: "Presencial"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Status</label>
<div>{getStatusBadge(selectedAppointment.status || 'agendada')}</div>
<label className="block text-sm font-medium text-gray-500 mb-1">
Status
</label>
<div>
{getStatusBadge(selectedAppointment.status || "agendada")}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Observações</label>
<p className="text-gray-900 whitespace-pre-wrap">{selectedAppointment.notes || '—'}</p>
<label className="block text-sm font-medium text-gray-500 mb-1">
Observações
</label>
<p className="text-gray-900 whitespace-pre-wrap">
{selectedAppointment.notes || "—"}
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
@ -741,5 +788,3 @@ export function SecretaryAppointmentList() {
</div>
);
}

View File

@ -66,11 +66,13 @@ 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 <= 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)}`;
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
@ -81,8 +83,13 @@ const formatPhone = (value: string): string => {
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)}`;
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({
@ -423,9 +430,14 @@ export function SecretaryDoctorList({
if (onOpenSchedule) {
onOpenSchedule(doctor.id);
} else {
sessionStorage.setItem("selectedDoctorForSchedule", doctor.id);
sessionStorage.setItem(
"selectedDoctorForSchedule",
doctor.id
);
// dispatch a custom event to inform parent (optional)
window.dispatchEvent(new CustomEvent("open-doctor-schedule"));
window.dispatchEvent(
new CustomEvent("open-doctor-schedule")
);
}
}}
title="Gerenciar agenda"
@ -501,7 +513,10 @@ export function SecretaryDoctorList({
type="text"
value={formData.cpf}
onChange={(e) =>
setFormData({ ...formData, cpf: formatCPF(e.target.value) })
setFormData({
...formData,
cpf: formatCPF(e.target.value),
})
}
className="form-input"
required
@ -649,7 +664,9 @@ export function SecretaryDoctorList({
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Visualizar Médico</h2>
<h2 className="text-xl font-semibold text-gray-900">
Visualizar Médico
</h2>
<button
onClick={() => setShowViewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
@ -661,19 +678,23 @@ export function SecretaryDoctorList({
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500">Nome</p>
<p className="text-gray-900 font-medium">{selectedDoctor.full_name}</p>
<p className="text-gray-900 font-medium">
{selectedDoctor.full_name}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Especialidade</p>
<p className="text-gray-900">{selectedDoctor.specialty || '—'}</p>
<p className="text-gray-900">
{selectedDoctor.specialty || "—"}
</p>
</div>
<div>
<p className="text-sm text-gray-500">CRM</p>
<p className="text-gray-900">{selectedDoctor.crm || '—'}</p>
<p className="text-gray-900">{selectedDoctor.crm || "—"}</p>
</div>
<div>
<p className="text-sm text-gray-500">Email</p>
<p className="text-gray-900">{selectedDoctor.email || '—'}</p>
<p className="text-gray-900">{selectedDoctor.email || "—"}</p>
</div>
</div>
</div>
@ -691,5 +712,3 @@ export function SecretaryDoctorList({
</div>
);
}

View File

@ -34,7 +34,7 @@ const weekdayToText = (weekday: Weekday | undefined | null): string => {
5: "Sexta-feira",
6: "Sábado",
};
return weekdayMap[weekday] || "Desconhecido";
};
@ -118,20 +118,23 @@ export function SecretaryDoctorSchedule() {
const loadDoctorSchedule = useCallback(async () => {
if (!selectedDoctorId) return;
console.log("[SecretaryDoctorSchedule] Carregando agenda do médico:", selectedDoctorId);
console.log(
"[SecretaryDoctorSchedule] Carregando agenda do médico:",
selectedDoctorId
);
setLoading(true);
try {
// Load availabilities
const availData = await availabilityService.list({
doctor_id: selectedDoctorId,
});
console.log("[SecretaryDoctorSchedule] Disponibilidades recebidas:", {
count: availData?.length || 0,
data: availData,
});
setAvailabilities(Array.isArray(availData) ? availData : []);
// Load appointments for the doctor
@ -154,7 +157,10 @@ export function SecretaryDoctorSchedule() {
});
setExceptions(Array.isArray(exceptionsData) ? exceptionsData : []);
} catch (error) {
console.error("[SecretaryDoctorSchedule] Erro ao carregar agenda:", error);
console.error(
"[SecretaryDoctorSchedule] Erro ao carregar agenda:",
error
);
toast.error("Erro ao carregar agenda do médico");
} finally {
setLoading(false);
@ -179,17 +185,17 @@ export function SecretaryDoctorSchedule() {
for (let i = 0; i < 42; i++) {
const dayDate = new Date(currentDatePointer);
const dayDateStr = dayDate.toISOString().split('T')[0];
const dayDateStr = dayDate.toISOString().split("T")[0];
// Filter appointments for this day
const dayAppointments = appointments.filter(apt => {
const dayAppointments = appointments.filter((apt) => {
if (!apt.scheduled_at) return false;
const aptDate = new Date(apt.scheduled_at).toISOString().split('T')[0];
const aptDate = new Date(apt.scheduled_at).toISOString().split("T")[0];
return aptDate === dayDateStr;
});
// Filter exceptions for this day
const dayExceptions = exceptions.filter(exc => {
const dayExceptions = exceptions.filter((exc) => {
return exc.date === dayDateStr;
});
@ -273,7 +279,7 @@ export function SecretaryDoctorSchedule() {
appointment_type: "presencial" as const,
active: true,
};
console.log("[SecretaryDoctorSchedule] Payload para criação:", payload);
return availabilityService.create(payload);
});
@ -285,25 +291,29 @@ export function SecretaryDoctorSchedule() {
selectedWeekdays.length > 1 ? "s" : ""
} com sucesso`
);
setShowAvailabilityDialog(false);
setSelectedWeekdays([]);
setStartTime("08:00");
setEndTime("18:00");
setDuration(30);
loadDoctorSchedule();
} catch (error: any) {
console.error("[SecretaryDoctorSchedule] Erro ao adicionar disponibilidade:", {
error,
message: error?.message,
response: error?.response?.data,
});
const errorMsg = error?.response?.data?.message ||
error?.response?.data?.hint ||
error?.message ||
"Erro ao adicionar disponibilidade";
console.error(
"[SecretaryDoctorSchedule] Erro ao adicionar disponibilidade:",
{
error,
message: error?.message,
response: error?.response?.data,
}
);
const errorMsg =
error?.response?.data?.message ||
error?.response?.data?.hint ||
error?.message ||
"Erro ao adicionar disponibilidade";
toast.error(errorMsg);
}
};
@ -327,11 +337,15 @@ export function SecretaryDoctorSchedule() {
try {
const start = new Date(exceptionStartDate);
const end = new Date(exceptionEndDate);
// Criar exceções para cada dia no intervalo
const promises = [];
for (let date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) {
const dateStr = date.toISOString().split('T')[0];
for (
let date = new Date(start);
date <= end;
date.setDate(date.getDate() + 1)
) {
const dateStr = date.toISOString().split("T")[0];
promises.push(
availabilityService.createException({
doctor_id: selectedDoctorId,
@ -346,10 +360,14 @@ export function SecretaryDoctorSchedule() {
}
await Promise.all(promises);
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
toast.success(`Exceção adicionada para ${days} dia${days > 1 ? 's' : ''} com sucesso`);
const days =
Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) +
1;
toast.success(
`Exceção adicionada para ${days} dia${days > 1 ? "s" : ""} com sucesso`
);
setShowExceptionDialog(false);
setExceptionType("férias");
setExceptionStartDate("");
@ -358,13 +376,14 @@ export function SecretaryDoctorSchedule() {
setExceptionIsFullDay(true);
setExceptionStartTime("08:00");
setExceptionEndTime("18:00");
loadDoctorSchedule();
} catch (error: any) {
console.error("Erro ao adicionar exceção:", error);
const errorMsg = error?.response?.data?.message ||
error?.message ||
"Erro ao adicionar exceção";
const errorMsg =
error?.response?.data?.message ||
error?.message ||
"Erro ao adicionar exceção";
toast.error(errorMsg);
}
};
@ -397,27 +416,40 @@ export function SecretaryDoctorSchedule() {
active: editActive,
};
console.log("[SecretaryDoctorSchedule] Dados de atualização:", updateData);
console.log(
"[SecretaryDoctorSchedule] Dados de atualização:",
updateData
);
const result = await availabilityService.update(editingAvailability.id, updateData);
const result = await availabilityService.update(
editingAvailability.id,
updateData
);
console.log("[SecretaryDoctorSchedule] Resultado da atualização:", result);
console.log(
"[SecretaryDoctorSchedule] Resultado da atualização:",
result
);
toast.success("Disponibilidade atualizada com sucesso");
setShowEditDialog(false);
setEditingAvailability(null);
loadDoctorSchedule();
} catch (error: any) {
console.error("[SecretaryDoctorSchedule] Erro ao atualizar disponibilidade:", {
error,
message: error?.message,
response: error?.response,
data: error?.response?.data,
});
const errorMessage = error?.response?.data?.message ||
error?.message ||
"Erro ao atualizar disponibilidade";
console.error(
"[SecretaryDoctorSchedule] Erro ao atualizar disponibilidade:",
{
error,
message: error?.message,
response: error?.response,
data: error?.response?.data,
}
);
const errorMessage =
error?.response?.data?.message ||
error?.message ||
"Erro ao atualizar disponibilidade";
toast.error(errorMessage);
}
};
@ -560,16 +592,17 @@ export function SecretaryDoctorSchedule() {
<div className="text-sm text-gray-700 mb-1 font-medium">
{day.date.getDate()}
</div>
{/* Exceções (bloqueios e disponibilidades extras) */}
{day.exceptions.map((exc, i) => {
const timeRange = exc.start_time && exc.end_time
? `${exc.start_time} - ${exc.end_time}`
: "Dia inteiro";
const tooltipText = exc.reason
const timeRange =
exc.start_time && exc.end_time
? `${exc.start_time} - ${exc.end_time}`
: "Dia inteiro";
const tooltipText = exc.reason
? `${timeRange} - ${exc.reason}`
: timeRange;
return (
<div
key={`exc-${i}`}
@ -587,12 +620,12 @@ export function SecretaryDoctorSchedule() {
{/* Consultas agendadas */}
{day.appointments.map((apt, i) => {
const time = apt.scheduled_at
? new Date(apt.scheduled_at).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
const time = apt.scheduled_at
? new Date(apt.scheduled_at).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
: '';
: "";
return (
<div
key={`apt-${i}`}
@ -701,11 +734,11 @@ export function SecretaryDoctorSchedule() {
>
<div>
<p className="font-medium text-gray-900">
{new Date(exc.date).toLocaleDateString('pt-BR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
{new Date(exc.date).toLocaleDateString("pt-BR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
<p className="text-sm text-gray-600">
@ -725,7 +758,9 @@ export function SecretaryDoctorSchedule() {
: "bg-purple-100 text-purple-700"
}`}
>
{exc.kind === "bloqueio" ? "Bloqueio" : "Disponibilidade Extra"}
{exc.kind === "bloqueio"
? "Bloqueio"
: "Disponibilidade Extra"}
</span>
<button
onClick={async () => {
@ -733,7 +768,7 @@ export function SecretaryDoctorSchedule() {
window.confirm(
`Tem certeza que deseja remover esta exceção?\n\nData: ${new Date(
exc.date
).toLocaleDateString('pt-BR')}`
).toLocaleDateString("pt-BR")}`
)
) {
try {
@ -1082,5 +1117,3 @@ export function SecretaryDoctorSchedule() {
</div>
);
}

View File

@ -21,7 +21,10 @@ export function SecretaryReportList() {
const [showEditModal, setShowEditModal] = useState(false);
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [patients, setPatients] = useState<Patient[]>([]);
const [requestedByNames, setRequestedByNames] = useState<Record<string, string>>({});
const [doctors, setDoctors] = useState<any[]>([]);
const [requestedByNames, setRequestedByNames] = useState<
Record<string, string>
>({});
const [formData, setFormData] = useState({
patient_id: "",
exam: "",
@ -35,12 +38,12 @@ export function SecretaryReportList() {
useEffect(() => {
loadReports();
loadPatients();
loadDoctors();
}, []);
// Recarrega automaticamente quando o filtro de status muda
// (evita depender do clique em Buscar)
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter]);
@ -54,6 +57,15 @@ export function SecretaryReportList() {
}
};
const loadDoctors = async () => {
try {
const data = await doctorService.list({});
setDoctors(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
}
};
const handleOpenCreateModal = () => {
setFormData({
patient_id: "",
@ -120,7 +132,7 @@ export function SecretaryReportList() {
}
try {
await reportService.update(selectedReport.id, {
const updatedReport = await reportService.update(selectedReport.id, {
patient_id: formData.patient_id,
exam: formData.exam || undefined,
diagnosis: formData.diagnosis || undefined,
@ -130,10 +142,19 @@ export function SecretaryReportList() {
requested_by: formData.requested_by || undefined,
});
console.log("[SecretaryReportList] Relatório atualizado:", updatedReport);
console.log(
"[SecretaryReportList] Novo requested_by:",
formData.requested_by
);
toast.success("Relatório atualizado com sucesso!");
setShowEditModal(false);
setSelectedReport(null);
loadReports();
// Limpar cache de nomes antes de recarregar
setRequestedByNames({});
await loadReports();
} catch (error) {
console.error("Erro ao atualizar relatório:", error);
toast.error("Erro ao atualizar relatório");
@ -276,29 +297,55 @@ 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
const uniqueIds = [
...new Set(reportsList.map((r) => r.requested_by).filter(Boolean)),
];
try {
// Buscar todos os médicos de uma vez
const doctors = await doctorService.list({});
for (const id of uniqueIds) {
// Verificar se já é um nome (não é UUID)
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
id!
);
if (!isUUID) {
// Já é um nome direto (dados legados), manter como está
names[id!] = id!;
continue;
}
// É um UUID, procurar o médico
const doctor = doctors.find((d) => {
const doctorAny = d as any;
return doctorAny.user_id === id || d.id === id || doctorAny.id === id;
});
if (doctor && doctor.full_name) {
// Formatar o nome com "Dr." se não tiver
const doctorName = doctor.full_name;
names[id!] = /^dr\.?\s/i.test(doctorName)
? doctorName
: `Dr. ${doctorName}`;
} else {
// UUID não encontrado - médico pode ter sido deletado
// Mostrar mensagem mais amigável
names[id!] = "Médico não cadastrado";
}
} catch (error) {
console.error(`Erro ao buscar nome para ID ${id}:`, error);
names[id!] = id!; // Manter o valor original em caso de erro
}
} catch (error) {
console.error("Erro ao buscar nomes dos médicos:", error);
// Em caso de erro, manter os IDs originais
uniqueIds.forEach((id) => {
names[id!] = id!;
});
}
setRequestedByNames(names);
};
@ -307,7 +354,15 @@ export function SecretaryReportList() {
try {
// Se um filtro de status estiver aplicado, encaminhar para o serviço
// Cast explícito para o tipo esperado pelo serviço (ReportStatus)
const filters = statusFilter ? { status: statusFilter as "draft" | "completed" | "pending" | "cancelled" } : undefined;
const filters = statusFilter
? {
status: statusFilter as
| "draft"
| "completed"
| "pending"
| "cancelled",
}
: undefined;
console.log("[SecretaryReportList] loadReports filters:", filters);
const data = await reportService.list(filters);
console.log("✅ Relatórios carregados:", data);
@ -323,12 +378,12 @@ export function SecretaryReportList() {
});
}
setReports(reportsList);
// Carregar nomes dos solicitantes
if (reportsList.length > 0) {
await loadRequestedByNames(reportsList);
}
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum relatório encontrado na API");
}
@ -517,8 +572,9 @@ export function SecretaryReportList() {
{formatDate(report.created_at)}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{report.requested_by
? (requestedByNames[report.requested_by] || report.requested_by)
{report.requested_by
? requestedByNames[report.requested_by] ||
report.requested_by
: "—"}
</td>
<td className="px-6 py-4">
@ -609,6 +665,26 @@ export function SecretaryReportList() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Solicitado por
</label>
<select
value={formData.requested_by}
onChange={(e) =>
setFormData({ ...formData, requested_by: e.target.value })
}
className="form-input"
>
<option value="">Selecione um médico</option>
{doctors.map((doctor) => (
<option key={doctor.id} value={doctor.id}>
{doctor.full_name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Diagnóstico
@ -731,8 +807,9 @@ export function SecretaryReportList() {
Solicitado por
</label>
<p className="text-gray-900">
{selectedReport.requested_by
? (requestedByNames[selectedReport.requested_by] || selectedReport.requested_by)
{selectedReport.requested_by
? requestedByNames[selectedReport.requested_by] ||
selectedReport.requested_by
: "—"}
</p>
</div>
@ -889,15 +966,20 @@ export function SecretaryReportList() {
<label className="block text-sm font-medium text-gray-700 mb-2">
Solicitado por
</label>
<input
type="text"
<select
value={formData.requested_by}
onChange={(e) =>
setFormData({ ...formData, requested_by: e.target.value })
}
className="form-input"
placeholder="Nome do médico solicitante"
/>
>
<option value="">Selecione um médico</option>
{doctors.map((doctor) => (
<option key={doctor.id} value={doctor.id}>
{doctor.full_name}
</option>
))}
</select>
</div>
<div>
@ -954,5 +1036,3 @@ export function SecretaryReportList() {
</div>
);
}

View File

@ -8,12 +8,12 @@
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
/* Garantir que o texto nunca fique muito grande */
html {
font-size: 16px;
}
@media (max-width: 640px) {
html {
font-size: 14px;

View File

@ -87,6 +87,9 @@ const AcompanhamentoPaciente: React.FC = () => {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
const [selectedLaudo, setSelectedLaudo] = useState<Report | null>(null);
const [showLaudoModal, setShowLaudoModal] = useState(false);
const [requestedByNames, setRequestedByNames] = useState<
Record<string, string>
>({});
const pacienteId = user?.id || "";
const pacienteNome = user?.nome || "Paciente";
@ -99,7 +102,10 @@ const AcompanhamentoPaciente: React.FC = () => {
// Detecta se veio de navegação com estado para abrir aba específica
useEffect(() => {
if (location.state && (location.state as { activeTab?: string }).activeTab) {
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
@ -196,6 +202,32 @@ const AcompanhamentoPaciente: React.FC = () => {
fetchConsultas();
}, [fetchConsultas]);
// Função para carregar nomes dos médicos solicitantes
const loadRequestedByNames = useCallback(async (reports: Report[]) => {
const uniqueIds = [
...new Set(
reports.map((r) => r.requested_by).filter((id): id is string => !!id)
),
];
if (uniqueIds.length === 0) return;
try {
const doctors = await doctorService.list();
const nameMap: Record<string, string> = {};
uniqueIds.forEach((id) => {
const doctor = doctors.find((d) => d.id === id);
if (doctor && doctor.full_name) {
nameMap[id] = formatDoctorName(doctor.full_name);
}
});
setRequestedByNames(nameMap);
} catch (error) {
console.error("Erro ao buscar nomes dos médicos:", error);
}
}, []);
// Recarregar consultas quando mudar para a aba de consultas
const fetchLaudos = useCallback(async () => {
if (!pacienteId) return;
@ -203,6 +235,8 @@ const AcompanhamentoPaciente: React.FC = () => {
try {
const data = await reportService.list({ patient_id: pacienteId });
setLaudos(data);
// Carregar nomes dos médicos
await loadRequestedByNames(data);
} catch (error) {
console.error("Erro ao buscar laudos:", error);
toast.error("Erro ao carregar laudos");
@ -210,7 +244,7 @@ const AcompanhamentoPaciente: React.FC = () => {
} finally {
setLoadingLaudos(false);
}
}, [pacienteId]);
}, [pacienteId, loadRequestedByNames]);
useEffect(() => {
if (activeTab === "appointments") {
@ -273,8 +307,12 @@ const AcompanhamentoPaciente: React.FC = () => {
const consultasPassadasDashboard = todasConsultasPassadas.slice(0, 3);
// Para a página de consultas (com paginação)
const totalPaginasProximas = Math.ceil(todasConsultasProximas.length / consultasPorPagina);
const totalPaginasPassadas = Math.ceil(todasConsultasPassadas.length / consultasPorPagina);
const totalPaginasProximas = Math.ceil(
todasConsultasProximas.length / consultasPorPagina
);
const totalPaginasPassadas = Math.ceil(
todasConsultasPassadas.length / consultasPorPagina
);
const consultasProximas = todasConsultasProximas.slice(
(paginaProximas - 1) * consultasPorPagina,
@ -369,7 +407,9 @@ const AcompanhamentoPaciente: React.FC = () => {
<p className="font-medium text-gray-900 dark:text-white truncate text-sm sm:text-base">
{pacienteNome}
</p>
<p className="text-xs sm: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>
@ -649,9 +689,13 @@ const AcompanhamentoPaciente: React.FC = () => {
{getMedicoEspecialidade(c.medicoId)}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{format(new Date(c.dataHora), "dd/MM/yyyy - HH:mm", {
locale: ptBR,
})}
{format(
new Date(c.dataHora),
"dd/MM/yyyy - HH:mm",
{
locale: ptBR,
}
)}
</p>
</div>
</div>
@ -662,7 +706,8 @@ const AcompanhamentoPaciente: React.FC = () => {
onClick={() => setActiveTab("appointments")}
className="w-full mt-4 px-4 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Ver mais consultas ({todasConsultasProximas.length - 3} restantes)
Ver mais consultas ({todasConsultasProximas.length - 3}{" "}
restantes)
</button>
)}
</>
@ -698,10 +743,7 @@ const AcompanhamentoPaciente: React.FC = () => {
<User className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Editar Perfil</span>
</button>
<button
onClick={() => navigate("/ajuda")}
className="form-input"
>
<button onClick={() => navigate("/ajuda")} className="form-input">
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Central de Ajuda</span>
</button>
@ -776,16 +818,23 @@ const AcompanhamentoPaciente: React.FC = () => {
<div className="space-y-4">
{consultasProximas.map((c) => renderAppointmentCard(c))}
</div>
{/* Paginação Próximas Consultas */}
{totalPaginasProximas > 1 && (
<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
Mostrando {(paginaProximas - 1) * consultasPorPagina + 1} a{" "}
{Math.min(
paginaProximas * consultasPorPagina,
todasConsultasProximas.length
)}{" "}
de {todasConsultasProximas.length} consultas
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
onClick={() =>
setPaginaProximas(Math.max(1, paginaProximas - 1))
}
disabled={paginaProximas === 1}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
>
@ -795,7 +844,11 @@ const AcompanhamentoPaciente: React.FC = () => {
Página {paginaProximas} de {totalPaginasProximas}
</span>
<button
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
onClick={() =>
setPaginaProximas(
Math.min(totalPaginasProximas, paginaProximas + 1)
)
}
disabled={paginaProximas === totalPaginasProximas}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
>
@ -826,16 +879,23 @@ const AcompanhamentoPaciente: React.FC = () => {
<div className="space-y-4">
{consultasPassadas.map((c) => renderAppointmentCard(c, true))}
</div>
{/* Paginação Consultas Passadas */}
{totalPaginasPassadas > 1 && (
<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
Mostrando {(paginaPassadas - 1) * consultasPorPagina + 1} a{" "}
{Math.min(
paginaPassadas * consultasPorPagina,
todasConsultasPassadas.length
)}{" "}
de {todasConsultasPassadas.length} consultas
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
onClick={() =>
setPaginaPassadas(Math.max(1, paginaPassadas - 1))
}
disabled={paginaPassadas === 1}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
>
@ -845,7 +905,11 @@ const AcompanhamentoPaciente: React.FC = () => {
Página {paginaPassadas} de {totalPaginasPassadas}
</span>
<button
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
onClick={() =>
setPaginaPassadas(
Math.min(totalPaginasPassadas, paginaPassadas + 1)
)
}
disabled={paginaPassadas === totalPaginasPassadas}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
>
@ -1079,7 +1143,7 @@ const AcompanhamentoPaciente: React.FC = () => {
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-gray-50 dark:bg-slate-950">
{renderSidebar()}
{/* Mobile Header */}
<div className="lg:hidden bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 p-4 sticky top-0 z-10">
<div className="flex items-center justify-between">
@ -1097,7 +1161,9 @@ const AcompanhamentoPaciente: React.FC = () => {
<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>
<p className="text-xs text-gray-600 dark:text-gray-400">
Paciente
</p>
</div>
</div>
<button
@ -1110,7 +1176,7 @@ const AcompanhamentoPaciente: React.FC = () => {
<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) => {
@ -1143,7 +1209,9 @@ const AcompanhamentoPaciente: React.FC = () => {
</div>
<main className="flex-1 overflow-y-auto">
<div className="container mx-auto p-4 sm:p-6 lg:p-8">{renderContent()}</div>
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
{renderContent()}
</div>
</main>
{/* Modal de Visualização do Laudo */}
@ -1207,11 +1275,14 @@ const AcompanhamentoPaciente: React.FC = () => {
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",
})}
{new Date(selectedLaudo.created_at).toLocaleDateString(
"pt-BR",
{
day: "2-digit",
month: "long",
year: "numeric",
}
)}
</p>
</div>
{selectedLaudo.due_at && (
@ -1220,11 +1291,14 @@ const AcompanhamentoPaciente: React.FC = () => {
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",
})}
{new Date(selectedLaudo.due_at).toLocaleDateString(
"pt-BR",
{
day: "2-digit",
month: "long",
year: "numeric",
}
)}
</p>
</div>
)}
@ -1294,7 +1368,8 @@ const AcompanhamentoPaciente: React.FC = () => {
</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}
{requestedByNames[selectedLaudo.requested_by] ||
selectedLaudo.requested_by}
</p>
</div>
</div>
@ -1308,7 +1383,9 @@ const AcompanhamentoPaciente: React.FC = () => {
</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 }}
dangerouslySetInnerHTML={{
__html: selectedLaudo.content_html,
}}
/>
</div>
)}
@ -1334,5 +1411,3 @@ const AcompanhamentoPaciente: React.FC = () => {
};
export default AcompanhamentoPaciente;

View File

@ -225,8 +225,8 @@ const AgendamentoPaciente: React.FC = () => {
)}
</div>
</div>
<button
onClick={resetarAgendamento}
<button
onClick={resetarAgendamento}
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
>
Fazer Novo Agendamento
@ -247,7 +247,9 @@ const AgendamentoPaciente: React.FC = () => {
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
Bem-vindo(a), {pacienteLogado.nome}!
</h1>
<p className="opacity-90 text-sm sm:text-base">Agende sua consulta médica</p>
<p className="opacity-90 text-sm sm:text-base">
Agende sua consulta médica
</p>
</div>
<button
onClick={logout}
@ -286,215 +288,216 @@ const AgendamentoPaciente: React.FC = () => {
</div>
<div className="bg-white rounded-lg sm:rounded-xl shadow border border-gray-200 p-4 sm:p-6">
{/* Etapa 1: Seleção de Médico */}
{etapa === 1 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Selecione o Médico
</h2>
{/* Etapa 1: Seleção de Médico */}
{etapa === 1 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Selecione o Médico
</h2>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Médico/Especialidade
</label>
<select
value={agendamento.medicoId}
onChange={(e) => handleMedicoChange(e.target.value)}
className="form-input text-sm sm:text-base"
required
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico._id} value={medico._id}>
{medico.nome} - {medico.especialidade} (R${" "}
{medico.valorConsulta})
</option>
))}
</select>
</div>
<div className="flex justify-end pt-2">
<button
onClick={() => setEtapa(2)}
disabled={!agendamento.medicoId}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-sm sm:text-base"
>
Próximo
</button>
</div>
</div>
)}
{/* Etapa 2: Seleção de Data e Horário */}
{etapa === 2 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Selecione Data e Horário
</h2>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Data da Consulta
</label>
<select
value={agendamento.data}
onChange={(e) => handleDataChange(e.target.value)}
className="form-input text-sm sm:text-base"
required
>
<option value="">Selecione uma data</option>
{proximosSeteDias().map((dia) => (
<option key={dia.valor} value={dia.valor}>
{dia.label}
</option>
))}
</select>
</div>
{agendamento.data && agendamento.medicoId && (
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis
Médico/Especialidade
</label>
<AvailableSlotsPicker
doctorId={agendamento.medicoId}
date={agendamento.data}
onSelect={(t) =>
setAgendamento((prev) => ({ ...prev, horario: t }))
<select
value={agendamento.medicoId}
onChange={(e) => handleMedicoChange(e.target.value)}
className="form-input text-sm sm:text-base"
required
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico._id} value={medico._id}>
{medico.nome} - {medico.especialidade} (R${" "}
{medico.valorConsulta})
</option>
))}
</select>
</div>
<div className="flex justify-end pt-2">
<button
onClick={() => setEtapa(2)}
disabled={!agendamento.medicoId}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-sm sm:text-base"
>
Próximo
</button>
</div>
</div>
)}
{/* Etapa 2: Seleção de Data e Horário */}
{etapa === 2 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Selecione Data e Horário
</h2>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Data da Consulta
</label>
<select
value={agendamento.data}
onChange={(e) => handleDataChange(e.target.value)}
className="form-input text-sm sm:text-base"
required
>
<option value="">Selecione uma data</option>
{proximosSeteDias().map((dia) => (
<option key={dia.valor} value={dia.valor}>
{dia.label}
</option>
))}
</select>
</div>
{agendamento.data && agendamento.medicoId && (
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis
</label>
<AvailableSlotsPicker
doctorId={agendamento.medicoId}
date={agendamento.data}
onSelect={(t) =>
setAgendamento((prev) => ({ ...prev, horario: t }))
}
/>
</div>
)}
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
<button
onClick={() => setEtapa(1)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
>
Voltar
</button>
<button
onClick={() => setEtapa(3)}
disabled={!agendamento.horario}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
>
Próximo
</button>
</div>
</div>
)}
{/* Etapa 3: Informações Adicionais */}
{etapa === 3 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Informações da Consulta
</h2>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta
</label>
<select
value={agendamento.tipoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
tipoConsulta: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
>
<option value="primeira-vez">Primeira Consulta</option>
<option value="retorno">Retorno</option>
<option value="urgencia">Urgência</option>
</select>
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta
</label>
<textarea
value={agendamento.motivoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
motivoConsulta: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
rows={3}
placeholder="Descreva brevemente o motivo da consulta"
/>
</div>
)}
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
<button
onClick={() => setEtapa(1)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
>
Voltar
</button>
<button
onClick={() => setEtapa(3)}
disabled={!agendamento.horario}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
>
Próximo
</button>
</div>
</div>
)}
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Observações (opcional)
</label>
<textarea
value={agendamento.observacoes}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
observacoes: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
rows={2}
placeholder="Informações adicionais relevantes"
/>
</div>
{/* Etapa 3: Informações Adicionais */}
{etapa === 3 && (
<div className="space-y-4 sm:space-y-6">
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
Informações da Consulta
</h2>
{/* Resumo do Agendamento */}
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
Resumo do Agendamento:
</h3>
<div className="space-y-1 sm:space-y-1.5 text-xs sm:text-sm">
<p className="break-words">
<strong>Paciente:</strong> {pacienteLogado.nome}
</p>
<p className="break-words">
<strong>Médico:</strong> {medicoSelecionado?.nome}
</p>
<p>
<strong>Data:</strong>{" "}
{format(new Date(agendamento.data), "dd/MM/yyyy", {
locale: ptBR,
})}
</p>
<p>
<strong>Horário:</strong> {agendamento.horario}
</p>
<p>
<strong>Valor:</strong> R${" "}
{medicoSelecionado?.valorConsulta}
</p>
</div>
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta
</label>
<select
value={agendamento.tipoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
tipoConsulta: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
>
<option value="primeira-vez">Primeira Consulta</option>
<option value="retorno">Retorno</option>
<option value="urgencia">Urgência</option>
</select>
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta
</label>
<textarea
value={agendamento.motivoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
motivoConsulta: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
rows={3}
placeholder="Descreva brevemente o motivo da consulta"
/>
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
Observações (opcional)
</label>
<textarea
value={agendamento.observacoes}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
observacoes: e.target.value,
}))
}
className="form-input text-sm sm:text-base"
rows={2}
placeholder="Informações adicionais relevantes"
/>
</div>
{/* Resumo do Agendamento */}
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
Resumo do Agendamento:
</h3>
<div className="space-y-1 sm:space-y-1.5 text-xs sm:text-sm">
<p className="break-words">
<strong>Paciente:</strong> {pacienteLogado.nome}
</p>
<p className="break-words">
<strong>Médico:</strong> {medicoSelecionado?.nome}
</p>
<p>
<strong>Data:</strong>{" "}
{format(new Date(agendamento.data), "dd/MM/yyyy", {
locale: ptBR,
})}
</p>
<p>
<strong>Horário:</strong> {agendamento.horario}
</p>
<p>
<strong>Valor:</strong> R$ {medicoSelecionado?.valorConsulta}
</p>
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
<button
onClick={() => setEtapa(2)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
>
Voltar
</button>
<button
onClick={confirmarAgendamento}
disabled={loading}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
>
{loading ? "Agendando..." : "Confirmar Agendamento"}
</button>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
<button
onClick={() => setEtapa(2)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
>
Voltar
</button>
<button
onClick={confirmarAgendamento}
disabled={loading}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
>
{loading ? "Agendando..." : "Confirmar Agendamento"}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);

View File

@ -112,7 +112,10 @@ const Home: React.FC = () => {
};
return (
<div className="space-y-6 sm:space-y-8 px-4 sm:px-6 lg:px-8" id="main-content">
<div
className="space-y-6 sm:space-y-8 px-4 sm:px-6 lg:px-8"
id="main-content"
>
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
<RecoveryRedirect />
@ -257,9 +260,14 @@ const ActionCard: React.FC<ActionCardProps> = ({
<div
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-5 h-5 sm:w-6 sm:h-6 text-white`} aria-hidden="true" />
<Icon
className={`w-5 h-5 sm:w-6 sm:h-6 text-white`}
aria-hidden="true"
/>
</div>
<h3 className="text-base sm: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-xs sm:text-sm text-gray-600 mb-3 sm:mb-4 leading-relaxed">
{description}
</p>

View File

@ -64,25 +64,25 @@ const ListaPacientes: React.FC = () => {
<Users className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600 flex-shrink-0" />{" "}
Pacientes Cadastrados
</h2>
{loading && (
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
Carregando pacientes...
</div>
)}
{!loading && error && (
<div className="text-sm sm:text-base text-red-600 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg">
{error}
</div>
)}
{!loading && !error && pacientes.length === 0 && (
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
Nenhum paciente cadastrado.
</div>
)}
{!loading && !error && pacientes.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
{pacientes.map((paciente, idx) => (
@ -106,18 +106,23 @@ const ListaPacientes: React.FC = () => {
</div>
</div>
</div>
<div className="space-y-1.5 sm:space-y-2">
<div className="text-xs sm:text-sm text-gray-700">
<strong className="font-medium">CPF:</strong> {formatCPF(paciente.cpf)}
<strong className="font-medium">CPF:</strong>{" "}
{formatCPF(paciente.cpf)}
</div>
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
<span className="break-all">{formatEmail(paciente.email)}</span>
<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>
<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>{" "}

View File

@ -24,7 +24,7 @@ const ListaSecretarias: React.FC = () => {
<UserPlus className="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />{" "}
Secretárias Cadastradas
</h2>
{secretarias.length === 0 ? (
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
Nenhuma secretária cadastrada.
@ -45,7 +45,7 @@ const ListaSecretarias: React.FC = () => {
{sec.nome}
</span>
</div>
<div className="space-y-1.5 sm:space-y-2">
<div className="text-xs sm:text-sm text-gray-700">
<strong className="font-medium">CPF:</strong> {sec.cpf}

View File

@ -273,7 +273,9 @@ const PainelAdmin: React.FC = () => {
const formattedCpf = formatCPF(userCpf);
// Formatar telefone celular se fornecido
const formattedPhoneMobile = userPhoneMobile ? formatPhone(userPhoneMobile) : "";
const formattedPhoneMobile = userPhoneMobile
? formatPhone(userPhoneMobile)
: "";
// Criar usuário com senha (método obrigatório com CPF)
await userService.createUserWithPassword({
@ -299,10 +301,18 @@ const PainelAdmin: React.FC = () => {
// Mostrar mensagem de erro detalhada
const errorMessage =
(error as { response?: { data?: { message?: string; error?: string } }; message?: string })
?.response?.data?.message ||
(error as { response?: { data?: { message?: string; error?: string } }; message?: string })
?.response?.data?.error ||
(
error as {
response?: { data?: { message?: string; error?: string } };
message?: string;
}
)?.response?.data?.message ||
(
error as {
response?: { data?: { message?: string; error?: string } };
message?: string;
}
)?.response?.data?.error ||
(error as { message?: string })?.message ||
"Erro ao criar usuário";
@ -699,7 +709,9 @@ const PainelAdmin: React.FC = () => {
}
// Limpar telefone (remover formatação)
const phoneLimpo = medicoData.phone_mobile ? medicoData.phone_mobile.replace(/\D/g, "") : undefined;
const phoneLimpo = medicoData.phone_mobile
? medicoData.phone_mobile.replace(/\D/g, "")
: undefined;
console.log("[PainelAdmin] Criando médico com API /create-doctor:", {
email: medicoData.email,
@ -841,20 +853,32 @@ const PainelAdmin: React.FC = () => {
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 <= 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)}`;
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 <= 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)}`;
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
@ -1693,19 +1717,27 @@ const PainelAdmin: React.FC = () => {
>
{availableRoles.map((role) => (
<option key={role} value={role}>
{role === "paciente" ? "Paciente" :
role === "medico" ? "Médico" :
role === "secretaria" ? "Secretária" :
role === "admin" ? "Administrador" :
role === "gestor" ? "Gestor" : role}
{role === "paciente"
? "Paciente"
: role === "medico"
? "Médico"
: role === "secretaria"
? "Secretária"
: role === "admin"
? "Administrador"
: role === "gestor"
? "Gestor"
: role}
</option>
))}
</select>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-semibold mb-3">Campos Opcionais</h3>
<h3 className="text-sm font-semibold mb-3">
Campos Opcionais
</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">
@ -1715,7 +1747,10 @@ const PainelAdmin: React.FC = () => {
type="text"
value={formUser.phone || ""}
onChange={(e) =>
setFormUser({ ...formUser, phone: formatPhone(e.target.value) })
setFormUser({
...formUser,
phone: formatPhone(e.target.value),
})
}
maxLength={15}
className="form-input"
@ -1730,7 +1765,9 @@ const PainelAdmin: React.FC = () => {
<input
type="text"
value={userPhoneMobile}
onChange={(e) => setUserPhoneMobile(formatPhone(e.target.value))}
onChange={(e) =>
setUserPhoneMobile(formatPhone(e.target.value))
}
maxLength={15}
className="form-input"
placeholder="(00) 00000-0000"
@ -1894,7 +1931,10 @@ const PainelAdmin: React.FC = () => {
required
value={formMedico.cpf}
onChange={(e) =>
setFormMedico({ ...formMedico, cpf: formatCPF(e.target.value) })
setFormMedico({
...formMedico,
cpf: formatCPF(e.target.value),
})
}
maxLength={14}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
@ -2229,5 +2269,3 @@ const PainelAdmin: React.FC = () => {
};
export default PainelAdmin;

View File

@ -101,7 +101,9 @@ const PainelMedico: React.FC = () => {
// Estados para perfil do médico
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [profileTab, setProfileTab] = useState<"personal" | "professional" | "security">("personal");
const [profileTab, setProfileTab] = useState<
"personal" | "professional" | "security"
>("personal");
const [profileData, setProfileData] = useState({
full_name: "",
email: "",
@ -1020,7 +1022,7 @@ const PainelMedico: React.FC = () => {
// Carregar dados do perfil do médico
const loadDoctorProfile = useCallback(async () => {
if (!doctorId) return;
try {
const doctor = await doctorService.getById(doctorId);
setProfileData({
@ -1114,7 +1116,9 @@ const PainelMedico: React.FC = () => {
{/* 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>
<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}
@ -1193,7 +1197,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.full_name}
onChange={(e) => handleProfileChange("full_name", e.target.value)}
onChange={(e) =>
handleProfileChange("full_name", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1206,7 +1212,9 @@ const PainelMedico: React.FC = () => {
<input
type="email"
value={profileData.email}
onChange={(e) => handleProfileChange("email", e.target.value)}
onChange={(e) =>
handleProfileChange("email", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1219,7 +1227,9 @@ const PainelMedico: React.FC = () => {
<input
type="tel"
value={profileData.phone}
onChange={(e) => handleProfileChange("phone", e.target.value)}
onChange={(e) =>
handleProfileChange("phone", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1244,7 +1254,9 @@ const PainelMedico: React.FC = () => {
<input
type="date"
value={profileData.birth_date}
onChange={(e) => handleProfileChange("birth_date", e.target.value)}
onChange={(e) =>
handleProfileChange("birth_date", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1256,7 +1268,9 @@ const PainelMedico: React.FC = () => {
</label>
<select
value={profileData.sex}
onChange={(e) => handleProfileChange("sex", e.target.value)}
onChange={(e) =>
handleProfileChange("sex", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
>
@ -1270,7 +1284,9 @@ const PainelMedico: React.FC = () => {
</div>
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Endereço</h3>
<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">
@ -1280,7 +1296,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.street}
onChange={(e) => handleProfileChange("street", e.target.value)}
onChange={(e) =>
handleProfileChange("street", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1293,7 +1311,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.number}
onChange={(e) => handleProfileChange("number", e.target.value)}
onChange={(e) =>
handleProfileChange("number", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1306,7 +1326,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.complement}
onChange={(e) => handleProfileChange("complement", e.target.value)}
onChange={(e) =>
handleProfileChange("complement", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1319,7 +1341,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.neighborhood}
onChange={(e) => handleProfileChange("neighborhood", e.target.value)}
onChange={(e) =>
handleProfileChange("neighborhood", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1332,7 +1356,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.city}
onChange={(e) => handleProfileChange("city", e.target.value)}
onChange={(e) =>
handleProfileChange("city", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1345,7 +1371,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.state}
onChange={(e) => handleProfileChange("state", e.target.value)}
onChange={(e) =>
handleProfileChange("state", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
maxLength={2}
@ -1359,7 +1387,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.cep}
onChange={(e) => handleProfileChange("cep", e.target.value)}
onChange={(e) =>
handleProfileChange("cep", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1385,7 +1415,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.crm}
onChange={(e) => handleProfileChange("crm", e.target.value)}
onChange={(e) =>
handleProfileChange("crm", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1398,7 +1430,9 @@ const PainelMedico: React.FC = () => {
<input
type="text"
value={profileData.specialty}
onChange={(e) => handleProfileChange("specialty", e.target.value)}
onChange={(e) =>
handleProfileChange("specialty", e.target.value)
}
disabled={!isEditingProfile}
className="form-input"
/>
@ -1468,7 +1502,9 @@ const PainelMedico: React.FC = () => {
<div className="flex flex-col lg:flex-row min-h-screen bg-gray-50 dark:bg-slate-950">
{renderSidebar()}
<main className="flex-1 overflow-y-auto">
<div className="container mx-auto p-4 sm:p-6 lg:p-8">{renderContent()}</div>
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
{renderContent()}
</div>
</main>
{/* Modals */}
@ -1595,5 +1631,3 @@ const PainelMedico: React.FC = () => {
};
export default PainelMedico;

View File

@ -81,9 +81,7 @@ export default function PainelSecretaria() {
>
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">
{tab.label.split(' ')[0]}
</span>
<span className="sm:hidden">{tab.label.split(" ")[0]}</span>
</button>
);
})}
@ -97,7 +95,10 @@ export default function PainelSecretaria() {
<SecretaryPatientList
onOpenAppointment={(patientId: string) => {
// store selected patient for appointment and switch to consultas tab
sessionStorage.setItem("selectedPatientForAppointment", patientId);
sessionStorage.setItem(
"selectedPatientForAppointment",
patientId
);
setActiveTab("consultas");
}}
/>
@ -118,5 +119,3 @@ export default function PainelSecretaria() {
</div>
);
}

View File

@ -220,7 +220,9 @@ export default function PerfilMedico() {
{/* Avatar Card */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Foto de Perfil</h2>
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
Foto de Perfil
</h2>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
<AvatarUpload
userId={user?.id}
@ -235,7 +237,9 @@ export default function PerfilMedico() {
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
{formData.full_name}
</p>
<p className="text-gray-500 text-xs sm:text-sm truncate">{formData.specialty}</p>
<p className="text-gray-500 text-xs sm:text-sm truncate">
{formData.specialty}
</p>
<p className="text-xs sm:text-sm text-gray-500 truncate">
CRM: {formData.crm} - {formData.crm_state}
</p>
@ -564,5 +568,3 @@ export default function PerfilMedico() {
</div>
);
}

View File

@ -267,7 +267,9 @@ export default function PerfilPaciente() {
{/* Avatar Card */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Foto de Perfil</h2>
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
Foto de Perfil
</h2>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
<AvatarUpload
userId={user?.id}
@ -684,5 +686,3 @@ export default function PerfilPaciente() {
</div>
);
}

View File

@ -175,18 +175,28 @@ class ApiClient {
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
console.log("[ApiClient] GET Request:", url, "Params:", JSON.stringify(config?.params));
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',
dataLength: Array.isArray(response.data)
? response.data.length
: "not array",
});
console.log("[ApiClient] Response Data:", JSON.stringify(response.data, null, 2));
console.log(
"[ApiClient] Response Data:",
JSON.stringify(response.data, null, 2)
);
return response;
}
@ -201,7 +211,7 @@ class ApiClient {
data,
config,
});
try {
const response = await this.client.post<T>(url, data, config);
console.log("[ApiClient] POST Response:", {
@ -253,9 +263,18 @@ class ApiClient {
data,
config,
});
try {
const response = await this.client.patch<T>(url, data, config);
// Adicionar header Prefer para Supabase retornar os dados atualizados
const configWithPrefer = {
...config,
headers: {
...config?.headers,
Prefer: "return=representation",
},
};
const response = await this.client.patch<T>(url, data, configWithPrefer);
console.log("[ApiClient] PATCH Response:", {
status: response.status,
data: response.data,

View File

@ -24,27 +24,36 @@ class AppointmentService {
): Promise<GetAvailableSlotsResponse> {
try {
console.log("[AppointmentService] Chamando get-available-slots:", data);
// Usa callFunction para chamar a Edge Function
const response = await apiClient.callFunction<GetAvailableSlotsResponse>(
"get-available-slots",
data
);
console.log("[AppointmentService] Resposta get-available-slots:", response.data);
console.log(
"[AppointmentService] Resposta get-available-slots:",
response.data
);
return response.data;
} catch (error: any) {
console.error("[AppointmentService] ❌ Erro ao buscar slots:");
console.error("[AppointmentService] Status:", error?.response?.status);
console.error("[AppointmentService] Response Data:", JSON.stringify(error?.response?.data, null, 2));
console.error(
"[AppointmentService] Response Data:",
JSON.stringify(error?.response?.data, null, 2)
);
console.error("[AppointmentService] Message:", error?.message);
console.error("[AppointmentService] Input enviado:", JSON.stringify(data, null, 2));
console.error(
"[AppointmentService] Input enviado:",
JSON.stringify(data, null, 2)
);
throw new Error(
error?.response?.data?.message ||
error?.message ||
"Erro ao buscar horários disponíveis"
error?.response?.data?.message ||
error?.message ||
"Erro ao buscar horários disponíveis"
);
}
}

View File

@ -46,12 +46,12 @@ class AvailabilityService {
const response = await apiClient.get<any[]>(this.basePath, {
params,
});
console.log("[AvailabilityService] Resposta:", {
count: response.data?.length || 0,
isArray: Array.isArray(response.data),
});
// Converter weekday de string para número (compatibilidade com banco antigo)
const convertedData: DoctorAvailability[] = Array.isArray(response.data)
? response.data.map((item) => {
@ -64,21 +64,29 @@ class AvailabilityService {
friday: 5,
saturday: 6,
};
return {
...item,
weekday: typeof item.weekday === 'string'
? weekdayMap[item.weekday.toLowerCase()]
: item.weekday,
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));
console.log(
"[AvailabilityService] ✅ Convertido:",
convertedData.length,
"registros"
);
console.log(
"[AvailabilityService] Primeiro item convertido:",
JSON.stringify(convertedData[0], null, 2)
);
}
return convertedData;
}
@ -100,9 +108,9 @@ class AvailabilityService {
},
}
);
console.log("[AvailabilityService] Resposta da criação:", response.data);
return Array.isArray(response.data) ? response.data[0] : response.data;
}
@ -129,8 +137,11 @@ class AvailabilityService {
}
);
console.log("[AvailabilityService] Resposta da atualização:", response.data);
console.log(
"[AvailabilityService] Resposta da atualização:",
response.data
);
return Array.isArray(response.data) ? response.data[0] : response.data;
}

View File

@ -61,14 +61,41 @@ class ReportService {
* Nota: order_number não pode ser modificado
*/
async update(id: string, data: UpdateReportInput): Promise<Report> {
const response = await apiClient.patch<Report[]>(
console.log("[ReportService] update() - id:", id, "data:", data);
const response = await apiClient.patch<Report | Report[]>(
`${this.basePath}?id=eq.${id}`,
data
);
if (response.data && response.data.length > 0) {
return response.data[0];
console.log("[ReportService] update() - response status:", response.status);
console.log("[ReportService] update() - response.data:", response.data);
console.log(
"[ReportService] update() - response type:",
typeof response.data,
"isArray:",
Array.isArray(response.data)
);
// Supabase com Prefer: return=representation pode retornar array ou objeto
if (Array.isArray(response.data)) {
if (response.data.length > 0) {
return response.data[0];
}
// Array vazio - buscar o relatório atualizado
console.warn(
"[ReportService] update() - Array vazio, buscando relatório..."
);
return await this.getById(id);
} else if (response.data) {
return response.data as Report;
}
throw new Error("Relatório não encontrado");
// Última tentativa - buscar o relatório
console.warn(
"[ReportService] update() - Resposta vazia, buscando relatório..."
);
return await this.getById(id);
}
}

View File

@ -5,12 +5,12 @@ export default {
theme: {
extend: {
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
lg: ["1.125rem", { lineHeight: "1.75rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
"2xl": ["1.5rem", { lineHeight: "2rem" }],
},
colors: {
border: "hsl(var(--border))",