Atualizando

This commit is contained in:
guisilvagomes 2025-10-21 13:02:56 -03:00
parent 272f81f44b
commit 376e344506
148 changed files with 23429 additions and 15452 deletions

View File

@ -1,33 +1,52 @@
# Exemplo de configuração de ambiente para MEDICONNECT (Vite)
# Renomeie este arquivo para `.env` ou `.env.local` e ajuste os valores.
# NUNCA comite credenciais reais.
# ⚠️ ESTE ARQUIVO É APENAS UM EXEMPLO
# Renomeie para `.env` e configure as variáveis necessárias
# NUNCA commite o arquivo .env com valores reais!
# URL base do seu projeto Supabase (sem barra final)
VITE_SUPABASE_URL=https://SEU-PROJETO.supabase.co
# ===========================================
# FRONTEND (VITE) - Não precisa mais!
# ===========================================
# O frontend NÃO acessa o Supabase diretamente
# Todas as chamadas vão para as Netlify Functions
# Portanto, NÃO precisa de VITE_SUPABASE_* aqui
# Chave anônima pública (anon key) do Supabase
VITE_SUPABASE_ANON_KEY=coloque_sua_anon_key_aqui
# ===========================================
# NETLIFY FUNCTIONS (Backend)
# ===========================================
# Configure estas variáveis em:
# • Local: arquivo .env na raiz (opcional, Netlify Dev já injeta)
# • Produção: Netlify Dashboard → Site Settings → Environment Variables
# (Opcional) Override de chave se quiser testar outra instância
# VITE_SUPABASE_SERVICE_ROLE=NAO_COLOQUE_AQUI (NUNCA exponha service role no front)
# Supabase - OBRIGATÓRIAS
SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
SUPABASE_ANON_KEY=sua-chave-aqui
# Credenciais do usuário de serviço (opcional) para TokenManager (grant_type=password)
# Usado apenas se você mantiver um usuário técnico para chamadas server-like.
VITE_SERVICE_EMAIL=
VITE_SERVICE_PASSWORD=
# MongoDB - OPCIONAL (se você usa)
MONGODB_URI=mongodb+srv://usuario:senha@cluster.mongodb.net/database
# Ajustes de UI / Feature flags (exemplos futuros)
# VITE_FEATURE_CONSULTAS_NOVA_TABELA=true
# SMS API - OPCIONAL (se você usa envio de SMS)
SMS_API_KEY=sua-chave-sms-aqui
# Ambiente (dev | staging | prod)
VITE_APP_ENV=dev
# URL base da API (se diferente do Supabase REST) opcional
VITE_API_BASE_URL=
# Ativar mocks locais (false/true)
VITE_ENABLE_MOCKS=false
# Versão / build meta (pode ser injetado no CI)
VITE_APP_VERSION=0.0.0
VITE_BUILD_TIME=
# ===========================================
# NOTAS IMPORTANTES
# ===========================================
#
# 1. DESENVOLVIMENTO LOCAL:
# - As Netlify Functions pegam variáveis do Netlify Dev
# - Você pode criar um .env na raiz, mas não é obrigatório
#
# 2. PRODUÇÃO (Netlify):
# ⚠️ OBRIGATÓRIO: Configure em Site Settings → Environment Variables
# - SUPABASE_URL
# - SUPABASE_ANON_KEY
# - Outras variáveis que você usa
# - Após adicionar, faça um novo deploy!
#
# 3. SEGURANÇA:
# ✅ Use apenas SUPABASE_ANON_KEY (nunca service_role_key)
# ✅ Adicione .env no .gitignore
# ✅ Configure CORS no Supabase para seu domínio Netlify
# ❌ NUNCA exponha chaves secretas no frontend
#
# 4. ARQUITETURA:
# Frontend → Netlify Functions → Supabase
# (A chave do Supabase fica protegida nas Functions)

View File

@ -1,6 +1,90 @@
## MEDICONNECT Documentação Técnica e de Segurança
Aplicação SPA (React + Vite + TypeScript) consumindo Supabase (Auth, PostgREST, Edge Functions). Este documento consolida: variáveis de ambiente, arquitetura de autenticação, modelo de segurança atual, riscos, controles implementados e próximos passos.
Aplicação SPA (React + Vite + TypeScript) consumindo Supabase (Auth, PostgREST, Edge Functions) via **Netlify Functions**. Este documento consolida: variáveis de ambiente, arquitetura de autenticação, modelo de segurança atual, riscos, controles implementados e próximos passos.
---
## 🚀 Guias de Início Rápido
**Primeira vez rodando o projeto?** Escolha seu guia:
- 📖 **[QUICK-START.md](./QUICK-START.md)** - Comandos rápidos (5 minutos)
- 📚 **[README-INSTALACAO.md](./README-INSTALACAO.md)** - Guia completo com troubleshooting
- 🚢 **[DEPLOY.md](./DEPLOY.md)** - Como fazer deploy no Netlify (produção)
**Arquitetura da aplicação:**
```
Frontend (Vite/React) → Netlify Functions → Supabase API
```
As Netlify Functions protegem as credenciais do Supabase e funcionam como proxy/backend.
---
## ⚠️ MUDANÇAS RECENTES NA API (21/10/2025)
### Base de Dados Limpa
**Todos os usuários, pacientes, laudos e agendamentos foram deletados.** Motivo: limpeza de dados inconsistentes e roles incorretos.
### Novas Permissões (RLS)
#### 👨‍⚕️ Médicos:
- ✅ Veem **todos os pacientes**
- ✅ Veem apenas **seus próprios laudos** (filtro: `created_by = médico`)
- ✅ Veem apenas **seus próprios agendamentos** (filtro: `doctor_id = médico`)
- ✅ Editam apenas **seus próprios laudos e agendamentos**
#### 👤 Pacientes:
- ✅ Veem apenas **seus próprios dados**
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
- ✅ Veem apenas **seus próprios agendamentos**
#### 👩‍💼 Secretárias:
- ✅ Veem **todos os pacientes**
- ✅ Veem **todos os agendamentos**
- ✅ Veem **todos os laudos**
#### 👑 Admins/Gestores:
- ✅ **Acesso completo a tudo**
### Novos Endpoints de Criação (Atualizado 21/10 - tarde)
⚠️ **IMPORTANTE**: A API mudou! `create-doctor` e `create-patient` (REST) **NÃO ENVIAM MAGIC LINK** e **NÃO CRIAM AUTH USER**.
**`create-user`** - Criação completa com autenticação (RECOMENDADO):
- Obrigatório: `email`, `full_name`, `role`
- Opcional: `phone`, `create_patient_record`, `cpf`, `phone_mobile`
- 🔐 **Envia magic link** automaticamente para ativar conta
- Cria: Auth user + Profile + Role + (opcionalmente) registro em `patients`
- **Use este para criar qualquer usuário que precisa fazer login**
**`create-doctor`** (Edge Function) - Criação de médico SEM autenticação:
- Obrigatório: `cpf`, `crm`, `crm_uf`, `full_name`, `email`
- Validações: CRM (4-7 dígitos), CPF (11 dígitos), UF válido
- ❌ **NÃO cria auth user** - apenas registro em `doctors`
- Use apenas se precisar criar registro de médico sem login
**`POST /rest/v1/patients`** - Criação de paciente SEM autenticação:
- Obrigatório: `full_name`, `cpf`, `email`, `phone_mobile`, `created_by`
- ❌ **NÃO cria auth user** - apenas registro em `patients`
- Use apenas se precisar criar registro de paciente sem login
**Quando usar cada endpoint:**
- **`create-user`** com `role="medico"`: Admin criando médico que precisa fazer login
- **`create-user`** com `role="paciente"` + `create_patient_record=true`: Admin criando paciente com login
- **`create-user`** com `role="admin"/"secretaria"`: Criar usuários administrativos
- **`create-doctor`**: Apenas para registros de médicos sem necessidade de login (raro)
- **`POST /rest/v1/patients`**: Apenas para registros de pacientes sem necessidade de login (raro)
---
@ -23,20 +107,64 @@ Boas práticas:
## 2. Arquitetura de Autenticação
Fluxo atual (somente usuários finais):
### 🔐 Endpoints de Autenticação (Atualizado 21/10/2025)
1. Usuário envia email+senha -> `authService.login` (POST `/auth/v1/token` grant_type=password).
2. Resposta: `access_token` (curto prazo) + `refresh_token` (longo prazo) armazenados em `localStorage` (decisão provisória).
3. Interceptor (`api.ts`) anexa `Authorization: Bearer <access_token>` e `apikey: <anon_key>`.
4. Em resposta 401: wrapper tenta Refresh (grant_type=refresh_token). Se falhar, força logout.
5. `GET /auth/v1/user` fornece user base; `GET /functions/v1/user-info` enriquece (roles, profile, permissions).
#### **Login com Email e Senha**
Edge Function de criação de usuário (`/functions/v1/create-user`) é tentada primeiro; fallback manual executa sequência: signup -> profile -> role -> domínio (ex: doctors/patients table).
- **Endpoint**: `POST /auth/v1/token?grant_type=password`
- **Netlify Function**: `/auth-login`
- **Body**: `{ "email": "usuario@exemplo.com", "password": "senha123" }`
- **Resposta**: `{ access_token, token_type: "bearer", expires_in: 3600, refresh_token, user: { id, email } }`
- **Uso**: Login tradicional com credenciais
Motivos para não usar (neste momento) TokenManager técnico:
#### **Magic Link (Login sem Senha)**
- Elimina necessidade de usuário "service" exposto.
- RLS controla acesso por `auth.uid()` fluxo permanece coerente.
- **Endpoint**: `POST /auth/v1/otp`
- **Netlify Function**: `/auth-magic-link`
- **Body**: `{ "email": "usuario@exemplo.com" }`
- **Resposta**: `200 OK` (email enviado)
- **Uso**: Reenviar link de ativação ou login sem senha
- **Nota**: `create-user` já envia magic link automaticamente na criação
#### **Dados do Usuário Autenticado**
- **Endpoint**: `GET /auth/v1/user`
- **Netlify Function**: `/auth-user`
- **Headers**: `Authorization: Bearer <access_token>`
- **Resposta**: `{ id, email, created_at }`
- **Uso**: Verificar sessão atual
#### **Logout**
- **Endpoint**: `POST /auth/v1/logout`
- **Netlify Function**: `/auth-logout`
- **Headers**: `Authorization: Bearer <access_token>`
- **Resposta**: `204 No Content`
- **Uso**: Encerrar sessão e invalidar tokens
### 🔄 Fluxo de Autenticação
1. **Login**: Usuário envia email+senha → `authService.login``POST /auth-login`
2. **Tokens**: Resposta contém `access_token` (curto prazo) + `refresh_token` (longo prazo)
3. **Interceptor**: Anexa `Authorization: Bearer <access_token>` + `apikey` em todas as requisições
4. **Refresh**: Em 401, tenta renovar token automaticamente
5. **Enriquecimento**: `GET /user-info` busca roles, profile e permissions completos
### 🆕 Criação de Usuário
Edge Function `create-user` executa:
- Cria auth user
- Cria profile
- Atribui role
- **Envia magic link automaticamente**
- Opcionalmente cria registro em `patients` (se `create_patient_record=true`)
### 🔒 Motivos para Netlify Functions
- Protege `SUPABASE_ANON_KEY` no backend
- RLS controla acesso por `auth.uid()`
- Evita exposição de credenciais no frontend
---

View File

@ -0,0 +1,163 @@
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return { statusCode: 200, headers, body: "" };
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token n<>o fornecido" }),
};
}
const pathParts = event.path.split("/");
const appointmentId =
pathParts[pathParts.length - 1] !== "appointments"
? pathParts[pathParts.length - 1]
: null;
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/appointments`;
if (appointmentId && appointmentId !== "appointments") {
url += `?id=eq.${appointmentId}&select=*`;
} else if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: { apikey: SUPABASE_ANON_KEY, Authorization: authHeader },
});
let data = await response.json();
if (
appointmentId &&
appointmentId !== "appointments" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
if (!body.patient_id || !body.doctor_id || !body.scheduled_at) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigat<61>rios: patient_id, doctor_id, scheduled_at",
}),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/appointments`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
let data = await response.json();
if (Array.isArray(data) && data.length > 0) data = data[0];
return {
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
if (event.httpMethod === "PATCH") {
if (!appointmentId || appointmentId === "appointments") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do agendamento <20> obrigat<61>rio" }),
};
}
const body = JSON.parse(event.body || "{}");
const response = await fetch(
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) data = data[0];
return {
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
if (event.httpMethod === "DELETE") {
if (!appointmentId || appointmentId === "appointments") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do agendamento <20> obrigat<61>rio" }),
};
}
const response = await fetch(
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
{
method: "DELETE",
headers: { apikey: SUPABASE_ANON_KEY, Authorization: authHeader },
}
);
return { statusCode: response.status, headers, body: "" };
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ error: "Erro interno" }),
};
}
};

View File

@ -0,0 +1,153 @@
/**
* Netlify Function: Listar Atribuições
* GET /rest/v1/patient_assignments
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
// GET - Listar atribuições
if (event.httpMethod === "GET") {
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Monta URL com query params (se houver)
const queryString = event.queryStringParameters
? "?" +
new URLSearchParams(
event.queryStringParameters as Record<string, string>
).toString()
: "";
const response = await fetch(
`${SUPABASE_URL}/rest/v1/patient_assignments${queryString}`,
{
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
}
);
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro ao listar atribuições:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
}
// POST - Criar atribuição
if (event.httpMethod === "POST") {
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
const body = JSON.parse(event.body || "{}");
if (!body.patient_id || !body.user_id || !body.role) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "patient_id, user_id e role são obrigatórios",
}),
};
}
const response = await fetch(
`${SUPABASE_URL}/rest/v1/patient_assignments`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro ao criar atribuição:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
};

View File

@ -0,0 +1,95 @@
/**
* Netlify Function: Login
* Faz proxy seguro para API Supabase com apikey protegida
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
// Constantes da API (protegidas no backend)
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
interface LoginRequest {
email: string;
password: string;
}
export const handler: Handler = async (event: HandlerEvent) => {
// CORS headers
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Handle preflight
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
// Apenas POST é permitido
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
// Parse body
const body: LoginRequest = JSON.parse(event.body || "{}");
if (!body.email || !body.password) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "Email e senha são obrigatórios" }),
};
}
// Faz requisição para API Supabase COM a apikey protegida
const response = await fetch(
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify({
email: body.email,
password: body.password,
}),
}
);
const data = await response.json();
// Repassa a resposta para o frontend
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro no login:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,90 @@
/**
* Netlify Function: Logout
* Invalida a sessão do usuário no Supabase
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
// Pega o Bearer token do header
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Faz logout no Supabase
const response = await fetch(`${SUPABASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
// Logout retorna 204 No Content (sem body)
if (response.status === 204) {
return {
statusCode: 204,
headers,
body: "",
};
}
// Se não for 204, retorna o body da resposta
const data = await response.text();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: data || "{}",
};
} catch (error) {
console.error("Erro no logout:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,89 @@
/**
* Netlify Function: Magic Link
* Envia link de autenticação sem senha por email
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
// Constantes da API (protegidas no backend)
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
interface MagicLinkRequest {
email: string;
}
export const handler: Handler = async (event: HandlerEvent) => {
// CORS headers
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Handle preflight
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
// Apenas POST é permitido
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
// Parse body
const body: MagicLinkRequest = JSON.parse(event.body || "{}");
if (!body.email) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "Email é obrigatório" }),
};
}
// Faz requisição para API Supabase COM a apikey protegida
const response = await fetch(`${SUPABASE_URL}/auth/v1/otp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify({
email: body.email,
}),
});
const data = await response.json();
// Repassa a resposta para o frontend
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("[auth-magic-link] Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro ao enviar magic link",
details: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,87 @@
/**
* Netlify Function: Refresh Token
* Renova o access token usando o refresh token
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
interface RefreshTokenRequest {
refresh_token: string;
}
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const body: RefreshTokenRequest = JSON.parse(event.body || "{}");
if (!body.refresh_token) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "Refresh token é obrigatório" }),
};
}
// Faz requisição para renovar token no Supabase
const response = await fetch(
`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify({
refresh_token: body.refresh_token,
}),
}
);
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro ao renovar token:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,77 @@
/**
* Netlify Function: Auth User
* GET /auth/v1/user - Retorna dados do usuário autenticado
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
if (event.httpMethod === "GET") {
const response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de auth user:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,94 @@
/**
* Netlify Function: Delete Avatar
* DELETE /storage/v1/object/avatars/{userId}/avatar
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "DELETE, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "DELETE") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
const userId = event.queryStringParameters?.userId;
if (!userId) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "userId é obrigatório" }),
};
}
const response = await fetch(
`${SUPABASE_URL}/storage/v1/object/avatars/${userId}/avatar`,
{
method: "DELETE",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
}
);
// DELETE pode retornar 200 com body vazio
const contentType = response.headers.get("content-type");
const data = contentType?.includes("application/json")
? await response.json()
: {};
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro ao deletar avatar:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,145 @@
/**
* Netlify Function: Upload Avatar
* POST /storage/v1/object/avatars/{userId}/avatar
*
* Aceita JSON com base64 para simplificar o upload via Netlify Functions
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Extrai userId do query string
const userId = event.queryStringParameters?.userId;
if (!userId) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "userId é obrigatório" }),
};
}
// Parse JSON body com base64
let fileData: string;
let contentType: string;
try {
const body = JSON.parse(event.body || "{}");
fileData = body.fileData; // base64 string
contentType = body.contentType || "image/jpeg";
if (!fileData) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "fileData (base64) é obrigatório" }),
};
}
} catch {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Body deve ser JSON válido com fileData em base64",
}),
};
}
// Converte base64 para Buffer
const buffer = Buffer.from(fileData, "base64");
// Upload para Supabase Storage
const response = await fetch(
`${SUPABASE_URL}/storage/v1/object/avatars/${userId}/avatar`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": contentType,
"x-upsert": "true", // Sobrescreve se já existir
},
body: buffer,
}
);
const data = await response.json();
if (!response.ok) {
console.error("Erro do Supabase:", data);
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
error: data.error || "Erro ao fazer upload no Supabase",
details: data,
}),
};
}
return {
statusCode: 200,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Upload realizado com sucesso",
path: data.Key || data.path,
fullPath: data.Key || data.path,
}),
};
} catch (error) {
console.error("Erro no upload do avatar:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -1,193 +1,163 @@
import { Handler, HandlerEvent, HandlerContext } from "@netlify/functions";
import type { Handler, HandlerEvent } from "@netlify/functions";
interface Consulta {
id: string;
pacienteId: string;
medicoId: string;
dataHora: string;
status: string;
tipo?: string;
motivo?: string;
observacoes?: string;
valorPago?: number;
formaPagamento?: string;
created_at?: string;
updated_at?: string;
}
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
// Store em memória (temporário - em produção use Supabase ou outro DB)
const consultas: Consulta[] = [];
const handler: Handler = async (
event: HandlerEvent,
context: HandlerContext
) => {
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Content-Type": "application/json",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
};
// Handle CORS preflight
if (event.httpMethod === "OPTIONS") {
return { statusCode: 204, headers, body: "" };
return { statusCode: 200, headers, body: "" };
}
void context; // not used currently
const path = event.path.replace("/.netlify/functions/consultas", "");
const method = event.httpMethod;
try {
// LIST - GET /consultas
if (method === "GET" && !path) {
const queryParams = event.queryStringParameters || {};
let resultado = [...consultas];
// Filtrar por pacienteId
if (queryParams.patient_id) {
const patientId = queryParams.patient_id.replace("eq.", "");
resultado = resultado.filter((c) => c.pacienteId === patientId);
}
// Filtrar por medicoId
if (queryParams.doctor_id) {
const doctorId = queryParams.doctor_id.replace("eq.", "");
resultado = resultado.filter((c) => c.medicoId === doctorId);
}
// Filtrar por status
if (queryParams.status) {
const status = queryParams.status.replace("eq.", "");
resultado = resultado.filter((c) => c.status === status);
}
// Limit
if (queryParams.limit) {
const limit = parseInt(queryParams.limit);
resultado = resultado.slice(0, limit);
}
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 200,
statusCode: 401,
headers,
body: JSON.stringify(resultado),
body: JSON.stringify({ error: "Token n<>o fornecido" }),
};
}
// GET BY ID - GET /consultas/:id
if (method === "GET" && path.match(/^\/[^/]+$/)) {
const id = path.substring(1);
const consulta = consultas.find((c) => c.id === id);
const pathParts = event.path.split("/");
const appointmentId =
pathParts[pathParts.length - 1] !== "consultas"
? pathParts[pathParts.length - 1]
: null;
if (!consulta) {
return {
statusCode: 404,
headers,
body: JSON.stringify({ error: "Consulta não encontrada" }),
};
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/appointments`;
if (appointmentId && appointmentId !== "consultas") {
url += `?id=eq.${appointmentId}&select=*`;
} else if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: { apikey: SUPABASE_ANON_KEY, Authorization: authHeader },
});
let data = await response.json();
if (
appointmentId &&
appointmentId !== "consultas" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: 200,
headers,
body: JSON.stringify(consulta),
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
// CREATE - POST /consultas
if (method === "POST" && !path) {
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
const novaConsulta: Consulta = {
id: crypto.randomUUID(),
pacienteId: body.pacienteId,
medicoId: body.medicoId,
dataHora: body.dataHora,
status: body.status || "agendada",
tipo: body.tipo,
motivo: body.motivo,
observacoes: body.observacoes,
valorPago: body.valorPago,
formaPagamento: body.formaPagamento,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
consultas.push(novaConsulta);
if (!body.patient_id || !body.doctor_id || !body.scheduled_at) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigat<61>rios: patient_id, doctor_id, scheduled_at",
}),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/appointments`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
let data = await response.json();
if (Array.isArray(data) && data.length > 0) data = data[0];
return {
statusCode: 201,
headers,
body: JSON.stringify(novaConsulta),
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
// UPDATE - PATCH /consultas/:id
if ((method === "PATCH" || method === "PUT") && path.match(/^\/[^/]+$/)) {
const id = path.substring(1);
const index = consultas.findIndex((c) => c.id === id);
if (index === -1) {
if (event.httpMethod === "PATCH") {
if (!appointmentId || appointmentId === "consultas") {
return {
statusCode: 404,
statusCode: 400,
headers,
body: JSON.stringify({ error: "Consulta não encontrada" }),
body: JSON.stringify({ error: "ID do agendamento <20> obrigat<61>rio" }),
};
}
const body = JSON.parse(event.body || "{}");
consultas[index] = {
...consultas[index],
...body,
id, // Não permitir alterar ID
updated_at: new Date().toISOString(),
};
const response = await fetch(
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) data = data[0];
return {
statusCode: 200,
headers,
body: JSON.stringify(consultas[index]),
statusCode: response.status,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
};
}
// DELETE - DELETE /consultas/:id
if (method === "DELETE" && path.match(/^\/[^/]+$/)) {
const id = path.substring(1);
const index = consultas.findIndex((c) => c.id === id);
if (index === -1) {
if (event.httpMethod === "DELETE") {
if (!appointmentId || appointmentId === "consultas") {
return {
statusCode: 404,
statusCode: 400,
headers,
body: JSON.stringify({ error: "Consulta não encontrada" }),
body: JSON.stringify({ error: "ID do agendamento <20> obrigat<61>rio" }),
};
}
consultas.splice(index, 1);
return {
statusCode: 204,
headers,
body: "",
};
const response = await fetch(
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
{
method: "DELETE",
headers: { apikey: SUPABASE_ANON_KEY, Authorization: authHeader },
}
);
return { statusCode: response.status, headers, body: "" };
}
return {
statusCode: 404,
statusCode: 405,
headers,
body: JSON.stringify({ error: "Rota não encontrada" }),
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na função consultas:", error);
console.error("Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno do servidor",
message: error instanceof Error ? error.message : String(error),
}),
body: JSON.stringify({ error: "Erro interno" }),
};
}
};
export { handler };

View File

@ -0,0 +1,100 @@
/**
* Netlify Function: Create Doctor
* POST /create-doctor - Cria registro de médico com validações
* Não cria auth user - apenas registro na tabela doctors
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
const body = JSON.parse(event.body || "{}");
// Validação dos campos obrigatórios
if (
!body.email ||
!body.full_name ||
!body.cpf ||
!body.crm ||
!body.crm_uf
) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: email, full_name, cpf, crm, crm_uf",
}),
};
}
// Chama a Edge Function do Supabase para criar médico
const response = await fetch(`${SUPABASE_URL}/functions/v1/create-doctor`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro na API de create doctor:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,102 @@
/**
* Netlify Function: Create Patient
* POST /create-patient - Cria registro de paciente diretamente
* Não cria auth user - apenas registro na tabela patients
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
const body = JSON.parse(event.body || "{}");
// Validação dos campos obrigatórios
if (
!body.full_name ||
!body.cpf ||
!body.email ||
!body.phone_mobile ||
!body.created_by
) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error:
"Campos obrigatórios: full_name, cpf, email, phone_mobile, created_by",
}),
};
}
// Chama REST API do Supabase para criar paciente diretamente
const response = await fetch(`${SUPABASE_URL}/rest/v1/patients`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro na API de create patient:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,120 @@
/**
* Netlify Function: Create User
* POST /create-user - Cria novo usuário no sistema
* Requer permissão de admin, gestor ou secretaria
* Envia magic link automaticamente para o email
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
// create-user pode ser chamado SEM autenticação (para auto-registro)
// Se houver token, será usado; se não houver, usa apenas anon key
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
console.log(
"[create-user] Recebido body:",
JSON.stringify(body, null, 2)
);
console.log("[create-user] Auth header presente?", !!authHeader);
// Validação dos campos obrigatórios
if (!body.email || !body.full_name) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: email, full_name",
}),
};
}
if (!body.role && (!body.roles || body.roles.length === 0)) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "É necessário fornecer role ou roles",
}),
};
}
// Chama a Edge Function do Supabase para criar usuário
const fetchHeaders: Record<string, string> = {
apikey: SUPABASE_ANON_KEY,
"Content-Type": "application/json",
// Se houver token de usuário autenticado, usa ele; senão usa anon key
Authorization: authHeader || `Bearer ${SUPABASE_ANON_KEY}`,
};
console.log("[create-user] Chamando Supabase com headers:", {
hasAuthHeader: !!authHeader,
hasApikey: !!fetchHeaders.apikey,
authType: authHeader ? "User Token" : "Anon Key",
});
const response = await fetch(`${SUPABASE_URL}/functions/v1/create-user`, {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(body),
});
const data = await response.json();
console.log("[create-user] Resposta do Supabase:", {
status: response.status,
data: JSON.stringify(data, null, 2),
});
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de create user:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,217 @@
/**
* Netlify Function: doctor-availability
*
* Proxy para operações de disponibilidade dos médicos
* GET: Lista disponibilidades
* POST: Criar disponibilidade
* PATCH: Atualizar disponibilidade
* DELETE: Deletar disponibilidade
*/
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_API_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export default async (req: Request) => {
// Permitir CORS
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
try {
const url = new URL(req.url);
const authHeader = req.headers.get("Authorization");
// Extrair ID do path se existir
const pathParts = url.pathname.split("/");
const availabilityId = pathParts[pathParts.length - 1];
// GET: Listar disponibilidades
if (req.method === "GET") {
const select = url.searchParams.get("select") || "*";
const doctor_id = url.searchParams.get("doctor_id");
const active = url.searchParams.get("active");
const queryParams = new URLSearchParams();
queryParams.append("select", select);
if (doctor_id) queryParams.append("doctor_id", `eq.${doctor_id}`);
if (active !== null) queryParams.append("active", `eq.${active}`);
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_availability?${queryParams}`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "GET",
headers,
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// POST: Criar disponibilidade
if (req.method === "POST") {
const body = await req.json();
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_availability`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
Prefer: "return=representation",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// PATCH: Atualizar disponibilidade
if (req.method === "PATCH") {
if (!availabilityId || availabilityId === "doctor-availability") {
return new Response(
JSON.stringify({ error: "Availability ID is required" }),
{
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
const body = await req.json();
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_availability?id=eq.${availabilityId}`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
Prefer: "return=representation",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
const data = await response.json();
const result = Array.isArray(data) && data.length > 0 ? data[0] : data;
return new Response(JSON.stringify(result), {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// DELETE: Deletar disponibilidade
if (req.method === "DELETE") {
if (!availabilityId || availabilityId === "doctor-availability") {
return new Response(
JSON.stringify({ error: "Availability ID is required" }),
{
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_availability?id=eq.${availabilityId}`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "DELETE",
headers,
});
return new Response(null, {
status: response.status,
headers: {
"Access-Control-Allow-Origin": "*",
},
});
}
// Método não suportado
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("Error in doctor-availability function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
};

View File

@ -0,0 +1,169 @@
/**
* Netlify Function: doctor-exceptions
*
* Proxy para operações de exceções na agenda dos médicos
* GET: Lista exceções
* POST: Criar exceção
* DELETE: Deletar exceção
*/
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_API_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export default async (req: Request) => {
// Permitir CORS
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
try {
const url = new URL(req.url);
const authHeader = req.headers.get("Authorization");
// Extrair ID do path se existir
const pathParts = url.pathname.split("/");
const exceptionId = pathParts[pathParts.length - 1];
// GET: Listar exceções
if (req.method === "GET") {
const select = url.searchParams.get("select") || "*";
const doctor_id = url.searchParams.get("doctor_id");
const date = url.searchParams.get("date");
const queryParams = new URLSearchParams();
queryParams.append("select", select);
if (doctor_id) queryParams.append("doctor_id", `eq.${doctor_id}`);
if (date) queryParams.append("date", `eq.${date}`);
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_exceptions?${queryParams}`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "GET",
headers,
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// POST: Criar exceção
if (req.method === "POST") {
const body = await req.json();
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_exceptions`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
Prefer: "return=representation",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// DELETE: Deletar exceção
if (req.method === "DELETE") {
if (!exceptionId || exceptionId === "doctor-exceptions") {
return new Response(
JSON.stringify({ error: "Exception ID is required" }),
{
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
const supabaseUrl = `${SUPABASE_URL}/rest/v1/doctor_exceptions?id=eq.${exceptionId}`;
const headers: HeadersInit = {
apikey: SUPABASE_API_KEY,
"Content-Type": "application/json",
};
if (authHeader) {
headers["Authorization"] = authHeader;
}
const response = await fetch(supabaseUrl, {
method: "DELETE",
headers,
});
return new Response(null, {
status: response.status,
headers: {
"Access-Control-Allow-Origin": "*",
},
});
}
// Método não suportado
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("Error in doctor-exceptions function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
};

View File

@ -0,0 +1,237 @@
/**
* Netlify Function: Doctors CRUD
* GET /rest/v1/doctors - Lista médicos
* GET /rest/v1/doctors/{id} - Busca por ID
* POST /rest/v1/doctors - Cria médico
* PATCH /rest/v1/doctors/{id} - Atualiza médico
* DELETE /rest/v1/doctors/{id} - Deleta médico
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Extrai ID da URL se houver (doctors/123 ou doctors?id=123)
const pathParts = event.path.split("/");
const doctorId =
pathParts[pathParts.length - 1] !== "doctors"
? pathParts[pathParts.length - 1]
: null;
// GET - Listar ou buscar por ID
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/doctors`;
if (doctorId && doctorId !== "doctors") {
// Buscar por ID específico
url += `?id=eq.${doctorId}&select=*`;
} else if (event.queryStringParameters) {
// Adiciona filtros da query string
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
// Adiciona select=* se não tiver
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
let data = await response.json();
// Se buscar por ID, retorna o objeto diretamente (não array)
if (
doctorId &&
doctorId !== "doctors" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// POST - Criar médico
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
if (
!body.crm ||
!body.crm_uf ||
!body.full_name ||
!body.cpf ||
!body.email
) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: crm, crm_uf, full_name, cpf, email",
}),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/doctors`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
let data = await response.json();
// Supabase retorna array, pega o primeiro
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// PATCH - Atualizar médico
if (event.httpMethod === "PATCH") {
if (!doctorId || doctorId === "doctors") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do médico é obrigatório" }),
};
}
const body = JSON.parse(event.body || "{}");
const response = await fetch(
`${SUPABASE_URL}/rest/v1/doctors?id=eq.${doctorId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// DELETE - Deletar médico
if (event.httpMethod === "DELETE") {
if (!doctorId || doctorId === "doctors") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do médico é obrigatório" }),
};
}
const response = await fetch(
`${SUPABASE_URL}/rest/v1/doctors?id=eq.${doctorId}`,
{
method: "DELETE",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
}
);
return {
statusCode: response.status,
headers,
body: "",
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de médicos:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,95 @@
/**
* Netlify Function: Get Available Slots
* POST /functions/v1/get-available-slots - Busca horários disponíveis
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
// Validação dos campos obrigatórios
if (!body.doctor_id || !body.start_date || !body.end_date) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: doctor_id, start_date, end_date",
}),
};
}
const response = await fetch(
`${SUPABASE_URL}/functions/v1/get-available-slots`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de available slots:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,226 @@
/**
* Netlify Function: Patients CRUD
* GET /rest/v1/patients - Lista pacientes
* GET /rest/v1/patients/{id} - Busca por ID
* POST /rest/v1/patients - Cria paciente
* PATCH /rest/v1/patients/{id} - Atualiza paciente
* DELETE /rest/v1/patients/{id} - Deleta paciente
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Extrai ID da URL se houver
const pathParts = event.path.split("/");
const patientId =
pathParts[pathParts.length - 1] !== "patients"
? pathParts[pathParts.length - 1]
: null;
// GET - Listar ou buscar por ID
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/patients`;
if (patientId && patientId !== "patients") {
url += `?id=eq.${patientId}&select=*`;
} else if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
let data = await response.json();
if (
patientId &&
patientId !== "patients" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// POST - Criar paciente
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
if (!body.full_name || !body.cpf || !body.email || !body.phone_mobile) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: full_name, cpf, email, phone_mobile",
}),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/patients`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// PATCH - Atualizar paciente
if (event.httpMethod === "PATCH") {
if (!patientId || patientId === "patients") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do paciente é obrigatório" }),
};
}
const body = JSON.parse(event.body || "{}");
const response = await fetch(
`${SUPABASE_URL}/rest/v1/patients?id=eq.${patientId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// DELETE - Deletar paciente
if (event.httpMethod === "DELETE") {
if (!patientId || patientId === "patients") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do paciente é obrigatório" }),
};
}
const response = await fetch(
`${SUPABASE_URL}/rest/v1/patients?id=eq.${patientId}`,
{
method: "DELETE",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
}
);
return {
statusCode: response.status,
headers,
body: "",
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de pacientes:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,155 @@
/**
* Netlify Function: Profiles
* GET /rest/v1/profiles - Lista perfis
* GET /rest/v1/profiles/{id} - Busca por ID
* PATCH /rest/v1/profiles/{id} - Atualiza avatar_url
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, PATCH, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Extrai ID da URL se houver
const pathParts = event.path.split("/");
const profileId =
pathParts[pathParts.length - 1] !== "profiles"
? pathParts[pathParts.length - 1]
: null;
// GET - Listar ou buscar por ID
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/profiles`;
if (profileId && profileId !== "profiles") {
url += `?id=eq.${profileId}&select=*`;
} else if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
let data = await response.json();
if (
profileId &&
profileId !== "profiles" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// PATCH - Atualizar avatar_url
if (event.httpMethod === "PATCH") {
if (!profileId || profileId === "profiles") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do perfil é obrigatório" }),
};
}
const body = JSON.parse(event.body || "{}");
const response = await fetch(
`${SUPABASE_URL}/rest/v1/profiles?id=eq.${profileId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de perfis:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,197 @@
/**
* Netlify Function: Reports
* GET /rest/v1/reports - Lista relatórios
* GET /rest/v1/reports/{id} - Busca por ID
* POST /rest/v1/reports - Cria relatório
* PATCH /rest/v1/reports/{id} - Atualiza relatório
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Extrai ID da URL se houver
const pathParts = event.path.split("/");
const reportId =
pathParts[pathParts.length - 1] !== "reports"
? pathParts[pathParts.length - 1]
: null;
// GET - Listar ou buscar por ID
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/reports`;
if (reportId && reportId !== "reports") {
url += `?id=eq.${reportId}&select=*`;
} else if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
url += `?${params.toString()}`;
if (!params.has("select")) {
url += url.includes("?") ? "&select=*" : "?select=*";
}
} else {
url += "?select=*";
}
const response = await fetch(url, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
let data = await response.json();
if (
reportId &&
reportId !== "reports" &&
Array.isArray(data) &&
data.length > 0
) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// POST - Criar relatório
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
if (!body.patient_id) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campo obrigatório: patient_id",
}),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/reports`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
// PATCH - Atualizar relatório
if (event.httpMethod === "PATCH") {
if (!reportId || reportId === "reports") {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "ID do relatório é obrigatório" }),
};
}
const body = JSON.parse(event.body || "{}");
const response = await fetch(
`${SUPABASE_URL}/rest/v1/reports?id=eq.${reportId}`,
{
method: "PATCH",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
}
);
let data = await response.json();
if (Array.isArray(data) && data.length > 0) {
data = data[0];
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de relatórios:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,93 @@
/**
* Netlify Function: Send SMS
* POST /functions/v1/send-sms - Envia SMS via Twilio
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
// Validação dos campos obrigatórios
if (!body.phone_number || !body.message) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: phone_number, message",
}),
};
}
// Chama a função Supabase de enviar SMS
const response = await fetch(`${SUPABASE_URL}/functions/v1/send-sms`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de SMS:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,93 @@
/**
* Netlify Function: User Info By ID
* POST /user-info-by-id - Retorna dados de usuário específico (apenas admin/gestor)
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
const body = JSON.parse(event.body || "{}");
if (!body.user_id) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "Campo obrigatório: user_id" }),
};
}
// Chama a Edge Function do Supabase
const response = await fetch(
`${SUPABASE_URL}/functions/v1/user-info-by-id`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("Erro na API de user-info-by-id:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,81 @@
/**
* Netlify Function: User Info
* GET /functions/v1/user-info - Retorna informações completas do usuário autenticado
* Inclui: user, profile, roles e permissions calculadas
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
// Aceita tanto POST quanto GET para compatibilidade
if (event.httpMethod === "POST" || event.httpMethod === "GET") {
// Chama a Edge Function do Supabase (POST conforme doc 21/10/2025)
const response = await fetch(`${SUPABASE_URL}/functions/v1/user-info`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de user-info:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,161 @@
/**
* Netlify Function: User Roles
* GET /rest/v1/user_roles - Lista roles de usuários
* POST /rest/v1/user_roles - Adiciona role a um usuário
* DELETE /rest/v1/user_roles - Remove role de um usuário
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: "Token não fornecido" }),
};
}
if (event.httpMethod === "GET") {
let url = `${SUPABASE_URL}/rest/v1/user_roles?select=*`;
if (event.queryStringParameters) {
const params = new URLSearchParams(
event.queryStringParameters as Record<string, string>
);
const paramsStr = params.toString();
if (paramsStr) {
url += `&${paramsStr}`;
}
}
const response = await fetch(url, {
method: "GET",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
if (event.httpMethod === "POST") {
// Adicionar nova role para um usuário
const body = JSON.parse(event.body || "{}");
if (!body.user_id || !body.role) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "user_id e role são obrigatórios" }),
};
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/user_roles`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
user_id: body.user_id,
role: body.role,
}),
});
const data = await response.json();
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
if (event.httpMethod === "DELETE") {
// Remover role de um usuário
const params = event.queryStringParameters;
if (!params?.user_id || !params?.role) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "user_id e role são obrigatórios" }),
};
}
const url = `${SUPABASE_URL}/rest/v1/user_roles?user_id=eq.${params.user_id}&role=eq.${params.role}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
},
});
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ success: true }),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("Erro na API de user roles:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

File diff suppressed because it is too large Load Diff

View File

@ -12,27 +12,33 @@ import Home from "./pages/Home";
import LoginPaciente from "./pages/LoginPaciente";
import LoginSecretaria from "./pages/LoginSecretaria";
import LoginMedico from "./pages/LoginMedico";
import CadastroMedico from "./pages/CadastroMedico";
import AgendamentoPaciente from "./pages/AgendamentoPaciente";
import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente";
import CadastroSecretaria from "./pages/CadastroSecretaria";
import CadastroMedico from "./pages/CadastroMedico";
import CadastroPaciente from "./pages/CadastroPaciente";
import PainelMedico from "./pages/PainelMedico";
import PainelSecretaria from "./pages/PainelSecretaria";
import ProntuarioPaciente from "./pages/ProntuarioPaciente";
import TokenInspector from "./pages/TokenInspector";
import AdminDiagnostico from "./pages/AdminDiagnostico";
import TesteCadastroSquad18 from "./pages/TesteCadastroSquad18";
// import TesteCadastroSquad18 from "./pages/TesteCadastroSquad18"; // Arquivo removido
import PainelAdmin from "./pages/PainelAdmin";
import CentralAjudaRouter from "./pages/CentralAjudaRouter";
import PerfilMedico from "./pages/PerfilMedico";
import PerfilPaciente from "./pages/PerfilPaciente";
import ClearCache from "./pages/ClearCache";
function App() {
return (
<Router>
<Router
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<div className="app-root min-h-screen bg-gray-50 dark:bg-slate-900 dark:bg-gradient-to-br dark:from-slate-900 dark:to-slate-800 transition-colors duration-300">
<a
href="#main-content"
className="fixed -top-20 left-4 z-50 px-3 py-2 bg-blue-600 text-white rounded shadow transition-all focus:top-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
className="fixed -top-20 left-4 z-50 px-3 py-2 bg-blue-600 text-white rounded shadow transition-all focus:top-4 focus:outline-none focus-visual:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
Pular para o conteúdo
</a>
@ -40,15 +46,14 @@ function App() {
<main id="main-content" className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/clear-cache" element={<ClearCache />} />
<Route path="/paciente" element={<LoginPaciente />} />
<Route path="/login-secretaria" element={<LoginSecretaria />} />
<Route path="/login-medico" element={<LoginMedico />} />
<Route path="/cadastro-medico" element={<CadastroMedico />} />
<Route path="/cadastro-paciente" element={<CadastroPaciente />} />
<Route path="/cadastro/medico" element={<CadastroMedico />} />
<Route path="/dev/token" element={<TokenInspector />} />
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
<Route path="/teste-squad18" element={<TesteCadastroSquad18 />} />
<Route path="/cadastro" element={<CadastroSecretaria />} />
{/* <Route path="/teste-squad18" element={<TesteCadastroSquad18 />} /> */}
<Route path="/ajuda" element={<CentralAjudaRouter />} />
<Route element={<ProtectedRoute roles={["admin", "gestor"]} />}>
<Route path="/admin" element={<PainelAdmin />} />
@ -61,6 +66,7 @@ function App() {
}
>
<Route path="/painel-medico" element={<PainelMedico />} />
<Route path="/perfil-medico" element={<PerfilMedico />} />
</Route>
<Route
element={
@ -82,6 +88,7 @@ function App() {
element={<AcompanhamentoPaciente />}
/>
<Route path="/agendamento" element={<AgendamentoPaciente />} />
<Route path="/perfil-paciente" element={<PerfilPaciente />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -294,6 +294,19 @@ const AccessibilityMenu: React.FC = () => {
>
Resetar Configurações
</button>
<button
onClick={() => {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}}
className="w-full mt-2 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors font-medium flex items-center justify-center gap-2"
title="Limpa cache e sessão, recarrega a página"
>
🔄 Limpar Cache e Sessão
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-2">
Atalho: Alt + A | ESC fecha
</p>

View File

@ -1,4 +1,3 @@
import { useState, useEffect, useCallback } from "react";
import {
format,
@ -25,10 +24,13 @@ import {
CheckCircle2,
Search,
} from "lucide-react";
import { availabilityService } from "../services/availabilityService";
import { exceptionService } from "../services/exceptionService";
import { consultaService } from "../services/consultaService";
import { medicoService } from "../services/medicoService";
import {
availabilityService,
exceptionsService,
appointmentService,
smsService,
} from "../services";
import { useAuth } from "../hooks/useAuth";
interface Medico {
id: string;
@ -78,26 +80,23 @@ const dayOfWeekMap: { [key: number]: keyof Availability } = {
6: "sabado",
};
export default function AgendamentoConsulta() {
// ...
interface AgendamentoConsultaProps {
medicos: Medico[];
}
export default function AgendamentoConsulta({
medicos,
}: AgendamentoConsultaProps) {
const { user } = useAuth();
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>(medicos);
// ... outras declarações de hooks e funções ...
// Sempre que a lista de médicos da API mudar, atualiza o filtro
useEffect(() => {
if (selectedMedico) {
loadDoctorAvailability();
loadDoctorExceptions();
}
}, [selectedMedico, loadDoctorAvailability, loadDoctorExceptions]);
const [medicos, setMedicos] = useState<Medico[]>([]);
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>([]);
setFilteredMedicos(medicos);
}, [medicos]);
const [selectedMedico, setSelectedMedico] = useState<Medico | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
const [loading, setLoading] = useState(true);
// Calendar and scheduling states
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [availability, setAvailability] = useState<Availability | null>(null);
@ -112,31 +111,10 @@ export default function AgendamentoConsulta() {
const [bookingSuccess, setBookingSuccess] = useState(false);
const [bookingError, setBookingError] = useState("");
// Load doctors on mount
const loadMedicos = async () => {
try {
setLoading(true);
const data = await medicoService.listarMedicos();
// Supondo que data seja ApiResponse<MedicoListResponse>
if (data && Array.isArray(data.data)) {
setMedicos(data.data);
setFilteredMedicos(data.data);
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
} finally {
setLoading(false);
}
};
// Removido o carregamento interno de médicos, pois agora vem por prop
useEffect(() => {
loadMedicos();
}, []);
// Filter doctors based on search and specialty
useEffect(() => {
let filtered = medicos;
if (searchTerm) {
filtered = filtered.filter(
(medico) =>
@ -144,36 +122,36 @@ export default function AgendamentoConsulta() {
medico.especialidade.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (selectedSpecialty !== "all") {
filtered = filtered.filter(
(medico) => medico.especialidade === selectedSpecialty
);
}
setFilteredMedicos(filtered);
}, [searchTerm, selectedSpecialty, medicos]);
// Get unique specialties
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
// ... outras declarações de hooks ...
// ... outras funções e hooks ...
useEffect(() => {
if (selectedMedico) {
loadDoctorAvailability();
loadDoctorExceptions();
}
// eslint-disable-next-line
}, [selectedMedico]);
const loadDoctorAvailability = useCallback(async () => {
if (!selectedMedico) return;
try {
const response = await availabilityService.getAvailability(selectedMedico.id);
if (response && response.success && response.data && response.data.length > 0) {
const response = await availabilityService.getAvailability(
selectedMedico.id
);
if (
response &&
response.success &&
response.data &&
response.data.length > 0
) {
const avail = response.data[0];
setAvailability({
domingo: avail.domingo || { ativo: false, horarios: [] },
@ -187,8 +165,7 @@ export default function AgendamentoConsulta() {
} else {
setAvailability(null);
}
} catch (error) {
console.error("Erro ao carregar disponibilidade:", error);
} catch {
setAvailability(null);
}
}, [selectedMedico]);
@ -196,19 +173,19 @@ export default function AgendamentoConsulta() {
const loadDoctorExceptions = useCallback(async () => {
if (!selectedMedico) return;
try {
const response = await exceptionService.listExceptions({ doctor_id: selectedMedico.id });
const response = await exceptionService.listExceptions({
doctor_id: selectedMedico.id,
});
if (response && response.success && response.data) {
setExceptions(response.data as Exception[]);
} else {
setExceptions([]);
}
} catch (error) {
console.error("Erro ao carregar exceções:", error);
} catch {
setExceptions([]);
}
}, [selectedMedico]);
// Calculate available slots when date is selected
const calculateAvailableSlots = useCallback(() => {
if (!selectedDate || !availability) return;
const dateStr = format(selectedDate, "yyyy-MM-dd");
@ -236,7 +213,13 @@ export default function AgendamentoConsulta() {
} else {
setAvailableSlots([]);
}
}, [selectedDate, availability, exceptions, calculateAvailableSlots, selectedMedico]);
}, [
selectedDate,
availability,
exceptions,
calculateAvailableSlots,
selectedMedico,
]);
const isDateBlocked = (date: Date): boolean => {
const dateStr = format(date, "yyyy-MM-dd");
@ -245,30 +228,20 @@ export default function AgendamentoConsulta() {
const isDateAvailable = (date: Date): boolean => {
if (!availability) return false;
// Check if in the past
if (isBefore(date, startOfDay(new Date()))) return false;
// Check if blocked
if (isDateBlocked(date)) return false;
// Check if day has available schedule
const dayOfWeek = date.getDay();
const dayKey = dayOfWeekMap[dayOfWeek];
const daySchedule = availability[dayKey];
return (
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
);
};
// Calendar generation
const generateCalendarDays = () => {
const start = startOfMonth(currentMonth);
const end = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start, end });
// Add padding days from previous month
const startDay = start.getDay();
const prevMonthDays = [];
for (let i = startDay - 1; i >= 0; i--) {
@ -276,18 +249,11 @@ export default function AgendamentoConsulta() {
day.setDate(day.getDate() - (i + 1));
prevMonthDays.push(day);
}
return [...prevMonthDays, ...days];
};
const handlePrevMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));
};
const handleNextMonth = () => {
setCurrentMonth(addMonths(currentMonth, 1));
};
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
const handleSelectDoctor = (medico: Medico) => {
setSelectedMedico(medico);
setSelectedDate(undefined);
@ -296,43 +262,43 @@ export default function AgendamentoConsulta() {
setBookingSuccess(false);
setBookingError("");
};
const handleBookAppointment = () => {
if (selectedMedico && selectedDate && selectedTime && motivo) {
setShowConfirmDialog(true);
}
};
const confirmAppointment = async () => {
if (!selectedMedico || !selectedDate || !selectedTime) return;
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
try {
setBookingError("");
// Get current user from localStorage
const userStr = localStorage.getItem("user");
if (!userStr) {
setBookingError("Usuário não autenticado");
// Cria o agendamento na API real
const result = await consultasService.criar({
patient_id: user.id,
doctor_id: selectedMedico.id,
scheduled_at:
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00.000Z",
duration_minutes: 30,
appointment_type: appointmentType,
chief_complaint: motivo,
patient_notes: "",
insurance_provider: "",
});
if (!result.success) {
setBookingError(result.error || "Erro ao agendar consulta");
setShowConfirmDialog(false);
return;
}
const user = JSON.parse(userStr);
// Removido: dataHora não é usada
// Book appointment via API
await consultaService.criarConsulta({
paciente_id: user.id,
medico_id: selectedMedico.id,
data_hora: format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime,
tipo_consulta: "primeira_vez", // ou "retorno", "emergencia", "rotina" conforme lógica do sistema
motivo_consulta: motivo,
});
// Envia SMS de confirmação (se telefone disponível)
if (user.telefone) {
await smsService.enviarConfirmacaoConsulta(
user.telefone,
user.nome || "Paciente",
selectedMedico.nome,
format(selectedDate, "dd/MM/yyyy") + " às " + selectedTime
);
}
setBookingSuccess(true);
setShowConfirmDialog(false);
// Reset form after 3 seconds
setTimeout(() => {
setSelectedMedico(null);
setSelectedDate(undefined);
@ -340,183 +306,136 @@ export default function AgendamentoConsulta() {
setMotivo("");
setBookingSuccess(false);
}, 3000);
} catch (error) {
console.error("Erro ao agendar consulta:", error);
} catch (error) {
setBookingError(
error instanceof Error ? error.message : "Erro ao agendar consulta. Tente novamente."
error instanceof Error
? error.message
: "Erro ao agendar consulta. Tente novamente."
);
setShowConfirmDialog(false);
}
};
const calendarDays = generateCalendarDays();
return (
<div className="space-y-6 p-6">
{/* Success Message */}
<div className="space-y-6">
{bookingSuccess && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">
<p className="font-medium text-green-900">
Consulta agendada com sucesso!
</p>
<p className="text-sm text-green-700 dark:text-green-300">
<p className="text-sm text-green-700">
Você receberá uma confirmação por e-mail em breve.
</p>
</div>
</div>
)}
{/* Error Message */}
{bookingError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
<p className="text-red-900 dark:text-red-100">{bookingError}</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-600" />
<p className="text-red-900">{bookingError}</p>
</div>
)}
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Agendar Consulta
</h1>
<p className="text-gray-600 dark:text-gray-400">
<h1 className="text-2xl font-bold">Agendar Consulta</h1>
<p className="text-muted-foreground">
Escolha um médico e horário disponível
</p>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Buscar Médicos
</h2>
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="font-medium">
Buscar por nome ou especialidade
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Ex: Cardiologia, Dr. Silva..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
className="pl-9 w-full border rounded-lg py-2 px-3"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Especialidade
</label>
<label className="font-medium">Especialidade</label>
<select
value={selectedSpecialty}
onChange={(e) => setSelectedSpecialty(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
className="w-full border rounded-lg py-2 px-3"
>
<option value="all">Todas as especialidades</option>
{specialties.map((specialty) => (
<option key={specialty} value={specialty}>
{specialty}
{specialties.map((esp) => (
<option key={esp} value={esp}>
{esp}
</option>
))}
</select>
</div>
</div>
</div>
{/* Doctors List */}
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Carregando médicos...
</p>
</div>
) : filteredMedicos.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
<Stethoscope className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400">
Nenhum médico encontrado
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredMedicos.map((medico) => (
<div
key={medico.id}
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-6 transition-all ${
selectedMedico?.id === medico.id ? "ring-2 ring-blue-500" : ""
}`}
>
<div className="flex gap-4">
<div className="h-16 w-16 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xl font-bold">
{medico.nome
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2)}
</div>
<div className="flex-1 space-y-2">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{medico.nome}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{medico.especialidade}
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
CRM: {medico.crm}
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">
{medico.valorConsulta
? `R$ ${medico.valorConsulta.toFixed(2)}`
: "Consultar valor"}
</span>
<button
onClick={() => handleSelectDoctor(medico)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
selectedMedico?.id === medico.id
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{selectedMedico?.id === medico.id
? "Selecionado"
: "Selecionar"}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredMedicos.map((medico) => (
<div
key={medico.id}
className={`bg-white rounded-xl border p-6 flex gap-4 items-center ${
selectedMedico?.id === medico.id ? "border-blue-500" : ""
}`}
>
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold">
{medico.nome
.split(" ")
.map((n) => n[0])
.join("")}
</div>
<div className="flex-1 space-y-2">
<div>
<h3 className="font-semibold">{medico.nome}</h3>
<p className="text-muted-foreground">{medico.especialidade}</p>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<span>{medico.crm}</span>
{medico.valorConsulta ? (
<span>R$ {medico.valorConsulta.toFixed(2)}</span>
) : null}
</div>
<div className="flex items-center justify-between">
<span className="text-foreground">{medico.email || "-"}</span>
<div className="flex gap-2">
<button
className="px-3 py-1 rounded-lg border text-sm hover:bg-blue-50"
onClick={() => handleSelectDoctor(medico)}
>
{selectedMedico?.id === medico.id
? "Selecionado"
: "Selecionar"}
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Appointment Details */}
</div>
))}
</div>
{selectedMedico && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-6">
<div className="bg-white rounded-lg shadow p-6 space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Detalhes do Agendamento
</h2>
<p className="text-gray-600 dark:text-gray-400">
<h2 className="text-xl font-semibold">Detalhes do Agendamento</h2>
<p className="text-gray-600">
Consulta com {selectedMedico.nome} -{" "}
{selectedMedico.especialidade}
</p>
</div>
{/* Appointment Type */}
<div className="flex gap-2">
<button
onClick={() => setAppointmentType("presencial")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
appointmentType === "presencial"
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-gray-300 text-gray-600"
}`}
>
<MapPin className="h-5 w-5" />
@ -526,59 +445,49 @@ export default function AgendamentoConsulta() {
onClick={() => setAppointmentType("online")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
appointmentType === "online"
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-gray-300 text-gray-600"
}`}
>
<Video className="h-5 w-5" />
<span className="font-medium">Online</span>
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Calendar */}
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Selecione a Data
</label>
<label className="text-sm font-medium">Selecione a Data</label>
<div className="mt-2">
{/* Month/Year Navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={handlePrevMonth}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="font-semibold text-gray-900 dark:text-white">
<span className="font-semibold">
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
</span>
<button
onClick={handleNextMonth}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
{/* Calendar Grid */}
<div className="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
{/* Days of week header */}
<div className="grid grid-cols-7 bg-gray-50 dark:bg-gray-700">
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-7 bg-gray-50">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
(day) => (
<div
key={day}
className="text-center py-2 text-sm font-medium text-gray-600 dark:text-gray-400"
className="text-center py-2 text-sm font-medium text-gray-600"
>
{day}
</div>
)
)}
</div>
{/* Calendar days */}
<div className="grid grid-cols-7">
{calendarDays.map((day, index) => {
const isCurrentMonth = isSameMonth(day, currentMonth);
@ -589,53 +498,37 @@ export default function AgendamentoConsulta() {
isCurrentMonth && isDateAvailable(day);
const isBlocked = isCurrentMonth && isDateBlocked(day);
const isPast = isBefore(day, startOfDay(new Date()));
return (
<button
key={index}
onClick={() => isAvailable && setSelectedDate(day)}
disabled={!isAvailable}
className={`
aspect-square p-2 text-sm border-r border-b border-gray-200 dark:border-gray-700
${
!isCurrentMonth
? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-800"
: ""
}
${
isSelected
? "bg-blue-600 text-white font-bold"
: ""
}
${
isTodayDate && !isSelected
? "font-bold text-blue-600 dark:text-blue-400"
: ""
}
${
isAvailable && !isSelected
? "hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer"
: ""
}
${
isBlocked
? "bg-red-50 dark:bg-red-900/20 text-red-400 line-through"
: ""
}
${
isPast && !isBlocked
? "text-gray-400 dark:text-gray-600"
: ""
}
${
!isAvailable &&
!isBlocked &&
isCurrentMonth &&
!isPast
? "text-gray-300 dark:text-gray-600"
: ""
}
`}
className={`aspect-square p-2 text-sm border-r border-b border-gray-200 ${
!isCurrentMonth ? "text-gray-300 bg-gray-50" : ""
} ${
isSelected
? "bg-blue-600 text-white font-bold"
: ""
} ${
isTodayDate && !isSelected
? "font-bold text-blue-600"
: ""
} ${
isAvailable && !isSelected
? "hover:bg-blue-50 cursor-pointer"
: ""
} ${
isBlocked
? "bg-red-50 text-red-400 line-through"
: ""
} ${isPast && !isBlocked ? "text-gray-400" : ""} ${
!isAvailable &&
!isBlocked &&
isCurrentMonth &&
!isPast
? "text-gray-300"
: ""
}`}
>
{format(day, "d")}
</button>
@ -643,35 +536,30 @@ export default function AgendamentoConsulta() {
})}
</div>
</div>
{/* Legend */}
<div className="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<div className="mt-3 space-y-1 text-xs text-gray-600">
<p>🟢 Datas disponíveis</p>
<p>🔴 Datas bloqueadas</p>
</div>
</div>
</div>
</div>
{/* Time Slots and Details */}
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-sm font-medium">
Horários Disponíveis
</label>
{selectedDate ? (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
<p className="text-sm text-gray-600 mt-1">
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
locale: ptBR,
})}
</p>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
<p className="text-sm text-gray-600 mt-1">
Selecione uma data
</p>
)}
</div>
{selectedDate && availableSlots.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{availableSlots.map((slot) => (
@ -680,8 +568,8 @@ export default function AgendamentoConsulta() {
onClick={() => setSelectedTime(slot)}
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
selectedTime === slot
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
: "border-gray-300 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-700"
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
: "border-gray-300 hover:border-blue-300"
}`}
>
<Clock className="h-3 w-3" />
@ -690,22 +578,20 @@ export default function AgendamentoConsulta() {
))}
</div>
) : selectedDate ? (
<div className="p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-center">
<p className="text-gray-600 dark:text-gray-400">
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
<p className="text-gray-600">
Nenhum horário disponível para esta data
</p>
</div>
) : (
<div className="p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-center">
<p className="text-gray-600 dark:text-gray-400">
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
<p className="text-gray-600">
Selecione uma data para ver os horários
</p>
</div>
)}
{/* Reason */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="text-sm font-medium">
Motivo da Consulta *
</label>
<textarea
@ -713,17 +599,13 @@ export default function AgendamentoConsulta() {
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
rows={4}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white resize-none"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Summary */}
{selectedDate && selectedTime && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg space-y-2">
<h4 className="font-semibold text-gray-900 dark:text-white">
Resumo
</h4>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div className="p-4 bg-blue-50 rounded-lg space-y-2">
<h4 className="font-semibold">Resumo</h4>
<div className="space-y-1 text-sm text-gray-600">
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
<p> Horário: {selectedTime}</p>
<p>
@ -738,12 +620,10 @@ export default function AgendamentoConsulta() {
</div>
</div>
)}
{/* Confirm Button */}
<button
onClick={handleBookAppointment}
disabled={!selectedTime || !motivo.trim()}
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:cursor-not-allowed transition-colors"
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Confirmar Agendamento
</button>
@ -751,21 +631,16 @@ export default function AgendamentoConsulta() {
</div>
</div>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
Confirmar Agendamento
</h3>
<p className="text-gray-600 dark:text-gray-400">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-semibold">Confirmar Agendamento</h3>
<p className="text-gray-600">
Revise os detalhes da sua consulta antes de confirmar
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-300 font-bold">
<div className="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{selectedMedico?.nome
.split(" ")
.map((n) => n[0])
@ -773,16 +648,15 @@ export default function AgendamentoConsulta() {
.substring(0, 2)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
<p className="font-medium text-gray-900">
{selectedMedico?.nome}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
<p className="text-sm text-gray-600">
{selectedMedico?.especialidade}
</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div className="space-y-2 text-sm text-gray-600">
<p>
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
</p>
@ -796,19 +670,16 @@ export default function AgendamentoConsulta() {
{selectedMedico?.valorConsulta && (
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
)}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="font-medium text-gray-900 dark:text-white mb-1">
Motivo:
</p>
<p className="text-gray-600 dark:text-gray-400">{motivo}</p>
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="font-medium text-gray-900 mb-1">Motivo:</p>
<p className="text-gray-600">{motivo}</p>
</div>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={() => setShowConfirmDialog(false)}
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancelar
</button>

View File

@ -0,0 +1,92 @@
import React, { useState } from "react";
import { format, addDays } from "date-fns";
import toast from "react-hot-toast";
import { consultasLocalService } from "../services/consultasLocalService";
interface Medico {
id: string;
nome: string;
especialidade: string;
crm: string;
valorConsulta?: number;
}
interface AgendamentoConsultaSimplesProps {
medico: Medico | null;
}
export default function AgendamentoConsultaSimples({ medico }: AgendamentoConsultaSimplesProps) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [motivo, setMotivo] = useState("");
const [loading, setLoading] = useState(false);
const handleConfirmAppointment = async () => {
try {
if (!medico || !selectedDate) {
toast.error("Selecione um médico e uma data válida.");
return;
}
const pacienteId = "default";
const dataHoraFormatted =
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00";
consultasLocalService.saveConsulta({
medicoId: medico.id,
medicoNome: medico.nome,
especialidade: medico.especialidade,
dataHora: dataHoraFormatted,
tipo: "presencial",
motivo: motivo.trim(),
status: "agendada",
valorConsulta: medico.valorConsulta || 0,
pacienteId
});
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success("Consulta agendada com sucesso!");
setSelectedDate(null);
setSelectedTime("");
setMotivo("");
} catch (error) {
console.error("Erro ao agendar consulta:", error);
toast.error("Erro ao agendar consulta. Tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h2 className="text-xl font-bold mb-4">Agendar Consulta</h2>
{medico ? (
<>
<div className="mb-4">
<label>Data:</label>
<select value={selectedDate?.toISOString() || ""} onChange={e => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}>
<option value="">Selecione uma data</option>
{Array.from({ length: 30 }, (_, i) => {
const date = addDays(new Date(), i + 1);
if (date.getDay() === 0) return null;
return (
<option key={date.toISOString()} value={date.toISOString()}>{date.toLocaleDateString()}</option>
);
})}
</select>
</div>
<div className="mb-4">
<label>Horário:</label>
<input value={selectedTime} onChange={e => setSelectedTime(e.target.value)} placeholder="Ex: 09:00" />
</div>
<div className="mb-4">
<label>Motivo:</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da consulta" />
</div>
<button onClick={handleConfirmAppointment} disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded">
Agendar
</button>
</>
) : (
<div className="text-red-600">Médico não encontrado.</div>
)}
</div>
);
}

View File

@ -0,0 +1,418 @@
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "./MetricCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Calendar } from "@/components/ui/calendar";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ENDPOINTS } from "../services/endpoints";
import api from "../services/api";
// Adapte conforme o seu projeto
const months = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
export default function BookAppointment() {
const [doctors, setDoctors] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
new Date()
);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDoctor, setSelectedDoctor] = useState<any | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presential" | "online"
>("presential");
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
// Busca médicos da API
api
.get(ENDPOINTS.DOCTORS)
.then((res) => setDoctors(res.data))
.catch(() => setDoctors([]));
}, []);
const filteredDoctors = doctors.filter((doctor) => {
const matchesSearch =
doctor.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
doctor.specialty?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSpecialty =
selectedSpecialty === "all" || doctor.specialty === selectedSpecialty;
return matchesSearch && matchesSpecialty;
});
const handleBookAppointment = () => {
if (selectedDoctor && selectedTime) {
setShowConfirmDialog(true);
}
};
const confirmAppointment = async () => {
if (!selectedDoctor || !selectedTime || !selectedDate) return;
setLoading(true);
try {
await api.post(ENDPOINTS.APPOINTMENTS, {
doctor_id: selectedDoctor.id,
date: selectedDate.toISOString().split("T")[0],
time: selectedTime,
type: appointmentType,
reason,
});
alert("Agendamento realizado com sucesso!");
setShowConfirmDialog(false);
setSelectedDoctor(null);
setSelectedTime("");
setReason("");
} catch (e) {
alert("Erro ao agendar consulta");
} finally {
setLoading(false);
}
};
const handleMonthChange = (month: string) => {
const newDate = new Date(currentMonth.getFullYear(), Number(month));
setCurrentMonth(newDate);
};
const handleYearChange = (year: string) => {
const newDate = new Date(Number(year), currentMonth.getMonth());
setCurrentMonth(newDate);
};
return (
<div className="space-y-6">
<div>
<h1>Agendar Consulta</h1>
<p className="text-muted-foreground">
Escolha um médico e horário disponível
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Buscar Médicos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Buscar por nome ou especialidade</Label>
<Input
placeholder="Ex: Cardiologia, Dr. Silva..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Especialidade</Label>
<Select
value={selectedSpecialty}
onValueChange={setSelectedSpecialty}
>
<SelectTrigger>
<SelectValue placeholder="Todas as especialidades" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as especialidades</SelectItem>
{/* Adapte para especialidades reais */}
<SelectItem value="Cardiologia">Cardiologia</SelectItem>
<SelectItem value="Dermatologia">Dermatologia</SelectItem>
<SelectItem value="Ortopedia">Ortopedia</SelectItem>
<SelectItem value="Pediatria">Pediatria</SelectItem>
<SelectItem value="Ginecologia">Ginecologia</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredDoctors.map((doctor) => (
<Card
key={doctor.id}
className={selectedDoctor?.id === doctor.id ? "border-primary" : ""}
>
<CardContent className="pt-6">
<div className="flex gap-4">
{/* Adapte para seu componente de avatar */}
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center">
{doctor.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div className="flex-1 space-y-2">
<div>
<h3>{doctor.name}</h3>
<p className="text-muted-foreground">{doctor.specialty}</p>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<div className="flex items-center gap-1">
<span>{doctor.rating || "-"}</span>
</div>
<div className="flex items-center gap-1">
<span>{doctor.location || "-"}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-foreground">
{doctor.price || "-"}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedDoctor(doctor)}
>
Ver Agenda
</Button>
<Button
size="sm"
variant={
selectedDoctor?.id === doctor.id
? "default"
: "outline"
}
onClick={() => setSelectedDoctor(doctor)}
>
{selectedDoctor?.id === doctor.id
? "Selecionado"
: "Selecionar"}
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{selectedDoctor && (
<Card>
<CardHeader>
<CardTitle>Detalhes do Agendamento</CardTitle>
<CardDescription>
Consulta com {selectedDoctor.name} - {selectedDoctor.specialty}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs
value={appointmentType}
onValueChange={(v) =>
setAppointmentType(v as "presential" | "online")
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="presential">Presencial</TabsTrigger>
<TabsTrigger value="online">Online</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Select
value={String(currentMonth.getMonth())}
onValueChange={handleMonthChange}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((month, index) => (
<SelectItem key={index} value={String(index)}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(currentMonth.getFullYear())}
onValueChange={handleYearChange}
>
<SelectTrigger className="w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
month={currentMonth}
onMonthChange={setCurrentMonth}
className="rounded-md border w-full"
disabled={(date) =>
date < new Date() ||
date.getDay() === 0 ||
date.getDay() === 6
}
/>
<p className="text-muted-foreground">
🔴 Finais de semana não disponíveis
</p>
</div>
<div className="space-y-4">
<div>
<div className="mb-3">
<Label>Horários Disponíveis</Label>
<p className="text-muted-foreground">
{selectedDate?.toLocaleDateString("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
{/* Adapte para buscar horários reais da API se disponível */}
<div className="grid grid-cols-3 gap-2">
{["09:00", "10:00", "14:00", "15:00", "16:00"].map(
(slot) => (
<Button
key={slot}
variant={
selectedTime === slot ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedTime(slot)}
>
{slot}
</Button>
)
)}
</div>
</div>
<div className="space-y-2">
<Label>Motivo da Consulta</Label>
<Textarea
placeholder="Descreva brevemente o motivo da consulta..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
/>
</div>
<div className="p-4 bg-accent rounded-lg space-y-2">
<h4>Resumo</h4>
<div className="space-y-1 text-muted-foreground">
<p>Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p>Horário: {selectedTime || "Não selecionado"}</p>
<p>
Tipo:{" "}
{appointmentType === "online" ? "Online" : "Presencial"}
</p>
<p>Valor: {selectedDoctor.price || "-"}</p>
</div>
</div>
<Button
className="w-full"
disabled={!selectedTime || !reason || loading}
onClick={handleBookAppointment}
>
Confirmar Agendamento
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Agendamento</DialogTitle>
<DialogDescription>
Revise os detalhes da sua consulta antes de confirmar
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center">
{selectedDoctor?.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div>
<p className="text-foreground">{selectedDoctor?.name}</p>
<p className="text-muted-foreground">
{selectedDoctor?.specialty}
</p>
</div>
</div>
<div className="space-y-2 text-muted-foreground">
<p>📅 Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p> Horário: {selectedTime}</p>
<p>
📍 Tipo:{" "}
{appointmentType === "online"
? "Consulta Online"
: "Consulta Presencial"}
</p>
<p>💰 Valor: {selectedDoctor?.price || "-"}</p>
<div className="mt-4">
<p className="text-foreground">Motivo:</p>
<p>{reason}</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
Cancelar
</Button>
<Button onClick={confirmAppointment} disabled={loading}>
{loading ? "Agendando..." : "Confirmar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,16 +1,10 @@
import React, { useState, useEffect } from "react";
import {
Clock,
Plus,
Trash2,
Save,
Copy,
} from "lucide-react";
import { Clock, Plus, Trash2, Save, Copy } from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import availabilityService from "../services/availabilityService";
import exceptionService, { DoctorException } from "../services/exceptionService";
import { availabilityService, exceptionsService } from "../services/index";
import type { DoctorException } from "../services/exceptions/types";
import { useAuth } from "../hooks/useAuth";
interface TimeSlot {
@ -68,10 +62,11 @@ const DisponibilidadeMedico: React.FC = () => {
const loadAvailability = React.useCallback(async () => {
try {
setLoading(true);
// Usar listAvailability ao invés de getAvailability para ter os IDs individuais
const response = await availabilityService.listAvailability({ doctor_id: medicoId });
const availabilities = await availabilityService.list({
doctor_id: medicoId,
});
if (response && response.success && response.data && response.data.length > 0) {
if (availabilities && availabilities.length > 0) {
const newSchedule: Record<number, DaySchedule> = {};
// Inicializar todos os dias
@ -85,8 +80,8 @@ const DisponibilidadeMedico: React.FC = () => {
});
// Agrupar disponibilidades por dia da semana
response.data.forEach((avail) => {
const weekdayKey = daysOfWeek.find(d => d.dbKey === avail.weekday);
availabilities.forEach((avail: any) => {
const weekdayKey = daysOfWeek.find((d) => d.dbKey === avail.weekday);
if (!weekdayKey) return;
const dayKey = weekdayKey.key;
@ -127,14 +122,14 @@ const DisponibilidadeMedico: React.FC = () => {
const loadExceptions = React.useCallback(async () => {
try {
const response = await exceptionService.listExceptions({ doctor_id: medicoId });
if (response.success && response.data) {
setExceptions(response.data);
const blocked = response.data
.filter((exc) => exc.kind === "bloqueio" && exc.date)
.map((exc) => new Date(exc.date!));
setBlockedDates(blocked);
}
const exceptions = await exceptionsService.list({
doctor_id: medicoId,
});
setExceptions(exceptions);
const blocked = exceptions
.filter((exc: any) => exc.kind === "bloqueio" && exc.date)
.map((exc: any) => new Date(exc.date!));
setBlockedDates(blocked);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
}
@ -182,25 +177,20 @@ const DisponibilidadeMedico: React.FC = () => {
};
const removeTimeSlot = async (dayKey: number, slotId: string) => {
const slot = schedule[dayKey]?.slots.find(s => s.id === slotId);
const slot = schedule[dayKey]?.slots.find((s) => s.id === slotId);
// Se o slot tem um ID do banco, deletar imediatamente
if (slot?.dbId) {
try {
const response = await availabilityService.deleteAvailability(slot.dbId);
if (response.success) {
toast.success("Horário removido com sucesso");
} else {
toast.error(response.error || "Erro ao remover horário");
return;
}
await availabilityService.delete(slot.dbId);
toast.success("Horário removido com sucesso");
} catch (error) {
console.error("Erro ao remover horário:", error);
toast.error("Erro ao remover horário");
return;
}
}
// Atualizar o estado local
setSchedule((prev) => ({
...prev,
@ -265,12 +255,12 @@ const DisponibilidadeMedico: React.FC = () => {
// Para cada dia, processar slots
daysOfWeek.forEach(({ key, dbKey }) => {
const daySchedule = schedule[key];
if (!daySchedule || !daySchedule.enabled) {
// Se o dia foi desabilitado, deletar todos os slots existentes
daySchedule?.slots.forEach((slot) => {
if (slot.dbId) {
requests.push(availabilityService.deleteAvailability(slot.dbId));
requests.push(availabilityService.delete(slot.dbId));
}
});
return;
@ -278,12 +268,30 @@ const DisponibilidadeMedico: React.FC = () => {
// Processar cada slot do dia
daySchedule.slots.forEach((slot) => {
const inicio = slot.inicio ? (slot.inicio.length === 5 ? `${slot.inicio}:00` : slot.inicio) : "00:00:00";
const fim = slot.fim ? (slot.fim.length === 5 ? `${slot.fim}:00` : slot.fim) : "00:00:00";
const minutes = Math.max(1, timeToMinutes(fim.slice(0,5)) - timeToMinutes(inicio.slice(0,5)));
const inicio = slot.inicio
? slot.inicio.length === 5
? `${slot.inicio}:00`
: slot.inicio
: "00:00:00";
const fim = slot.fim
? slot.fim.length === 5
? `${slot.fim}:00`
: slot.fim
: "00:00:00";
const minutes = Math.max(
1,
timeToMinutes(fim.slice(0, 5)) - timeToMinutes(inicio.slice(0, 5))
);
const payload = {
weekday: dbKey as "segunda" | "terca" | "quarta" | "quinta" | "sexta" | "sabado" | "domingo",
weekday: dbKey as
| "segunda"
| "terca"
| "quarta"
| "quinta"
| "sexta"
| "sabado"
| "domingo",
start_time: inicio,
end_time: fim,
slot_minutes: minutes,
@ -293,13 +301,15 @@ const DisponibilidadeMedico: React.FC = () => {
if (slot.dbId) {
// Atualizar slot existente
requests.push(availabilityService.updateAvailability(slot.dbId, payload));
requests.push(availabilityService.update(slot.dbId, payload));
} else {
// Criar novo slot
requests.push(availabilityService.createAvailability({
doctor_id: medicoId,
...payload,
}));
requests.push(
availabilityService.create({
doctor_id: medicoId,
...payload,
})
);
}
});
});
@ -314,9 +324,14 @@ const DisponibilidadeMedico: React.FC = () => {
let successCount = 0;
results.forEach((r, idx) => {
if (r.status === "fulfilled") {
const val = r.value as { success?: boolean; error?: string; message?: string };
const val = r.value as {
success?: boolean;
error?: string;
message?: string;
};
if (val && val.success) successCount++;
else errors.push(`Item ${idx}: ${val?.error || val?.message || "Erro"}`);
else
errors.push(`Item ${idx}: ${val?.error || val?.message || "Erro"}`);
} else {
errors.push(`Item ${idx}: ${r.reason?.message || String(r.reason)}`);
}
@ -324,7 +339,9 @@ const DisponibilidadeMedico: React.FC = () => {
if (errors.length > 0) {
console.error("Erros ao salvar disponibilidades:", errors);
toast.error(`Algumas disponibilidades não foram salvas (${errors.length})`);
toast.error(
`Algumas disponibilidades não foram salvas (${errors.length})`
);
}
if (successCount > 0) {
toast.success(`${successCount} alteração(ões) salvas com sucesso!`);
@ -332,7 +349,10 @@ const DisponibilidadeMedico: React.FC = () => {
}
} catch (error) {
console.error("Erro ao salvar disponibilidade:", error);
const errorMessage = error instanceof Error ? error.message : "Erro ao salvar disponibilidade";
const errorMessage =
error instanceof Error
? error.message
: "Erro ao salvar disponibilidade";
toast.error(errorMessage);
} finally {
setSaving(false);
@ -351,10 +371,11 @@ const DisponibilidadeMedico: React.FC = () => {
if (dateExists) {
// Remove block
const exception = exceptions.find(
(exc) => exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
(exc) =>
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
);
if (exception && exception.id) {
await exceptionService.deleteException(exception.id);
await exceptionsService.delete(exception.id);
setBlockedDates(
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
);
@ -362,16 +383,14 @@ const DisponibilidadeMedico: React.FC = () => {
}
} else {
// Add block
const response = await exceptionService.createException({
await exceptionsService.create({
doctor_id: medicoId,
date: dateString,
kind: "bloqueio",
reason: "Data bloqueada pelo médico",
});
if (response.success) {
setBlockedDates([...blockedDates, selectedDate]);
toast.success("Data bloqueada");
}
setBlockedDates([...blockedDates, selectedDate]);
toast.success("Data bloqueada");
}
loadExceptions();
} catch (error) {

View File

@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
import { User, Stethoscope, Clipboard, ChevronDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { i18n } from "../i18n";
import { telemetry } from "../services/telemetry";
import { useAuth } from "../hooks/useAuth";
export type ProfileType = "patient" | "doctor" | "secretary" | null;
@ -95,8 +94,10 @@ export const ProfileSelector: React.FC = () => {
localStorage.setItem("mediconnect_selected_profile", profile.type);
}
// Telemetria
telemetry.trackProfileChange(previousProfile, profile.type || "none");
// Telemetria (optional - could be implemented later)
console.log(
`Profile changed: ${previousProfile} -> ${profile.type || "none"}`
);
// Navegar - condicional baseado em autenticação e role
let targetPath = profile.path; // default: caminho do perfil (login)

View File

@ -4,8 +4,9 @@ import { availabilityService } from "../../services";
import type {
DoctorAvailability,
Weekday,
AppointmentType,
} from "../../services/availabilityService";
} from "../../services/availability/types";
type AppointmentType = "presencial" | "telemedicina";
interface Props {
doctorId: string;
@ -47,11 +48,14 @@ const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
async function load() {
if (!doctorId) return;
setLoading(true);
const res = await availabilityService.listDoctorActiveAvailability(
doctorId
);
if (res.success && res.data) setList(res.data);
else toast.error(res.error || "Erro ao carregar disponibilidades");
try {
const data = await availabilityService.list({ doctor_id: doctorId });
setList(Array.isArray(data) ? data : []);
} catch (error) {
console.error("[AvailabilityManager] Erro ao carregar:", error);
toast.error("Erro ao carregar disponibilidades");
setList([]);
}
setLoading(false);
}
@ -93,39 +97,44 @@ const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
console.log("[AvailabilityManager] Enviando payload:", payload);
setSaving(true);
const res = await availabilityService.createAvailability(payload);
setSaving(false);
if (res.success) {
try {
await availabilityService.create(payload);
toast.success("Disponibilidade criada com sucesso!");
setForm((f) => ({ ...f, start_time: "09:00:00", end_time: "17:00:00" }));
void load();
} else {
console.error("[AvailabilityManager] Erro ao criar:", res.error);
toast.error(res.error || "Falha ao criar disponibilidade");
} catch (error) {
console.error("[AvailabilityManager] Erro ao criar:", error);
toast.error("Falha ao criar disponibilidade");
}
setSaving(false);
}
async function toggleActive(item: DoctorAvailability) {
if (!item.id) return;
const res = await availabilityService.updateAvailability(item.id, {
active: !item.active,
});
if (res.success) {
try {
await availabilityService.update(item.id, {
active: !item.active,
});
toast.success("Atualizado");
void load();
} else toast.error(res.error || "Falha ao atualizar");
} catch (error) {
console.error("[AvailabilityManager] Erro ao atualizar:", error);
toast.error("Falha ao atualizar");
}
}
async function remove(item: DoctorAvailability) {
if (!item.id) return;
const ok = confirm("Remover disponibilidade?");
if (!ok) return;
const res = await availabilityService.deleteAvailability(item.id);
if (res.success) {
try {
await availabilityService.delete(item.id);
toast.success("Removido");
void load();
} else toast.error(res.error || "Falha ao remover");
} catch (error) {
console.error("[AvailabilityManager] Erro ao remover:", error);
toast.error("Falha ao remover");
}
}
return (

View File

@ -1,9 +1,8 @@
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { appointmentService } from "../../services";
import pacienteService from "../../services/pacienteService";
import type { Appointment } from "../../services/appointmentService";
import { appointmentService, patientService } from "../../services/index";
import type { Appointment } from "../../services/appointments/types";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
interface Props {
@ -52,16 +51,12 @@ const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
async function loadAppointments() {
setLoading(true);
try {
const response = await appointmentService.listAppointments();
if (response.success && response.data) {
// Filtrar apenas do médico selecionado
const filtered = response.data.filter(
(apt) => apt.doctor_id === doctorId
);
setAppointments(filtered);
} else {
toast.error(response.error || "Erro ao carregar agendamentos");
}
const appointments = await appointmentService.list();
// Filtrar apenas do médico selecionado
const filtered = appointments.filter(
(apt: Appointment) => apt.doctor_id === doctorId
);
setAppointments(filtered);
} catch (error) {
console.error("Erro ao carregar agendamentos:", error);
toast.error("Erro ao carregar agendamentos");
@ -73,52 +68,16 @@ const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
async function loadPatients() {
// Carrega pacientes para mapear nome pelo id (render amigável)
try {
const res = await pacienteService.listPatients();
if (res && Array.isArray(res.data)) {
const map: Record<string, string> = {};
for (const p of res.data) {
if (p?.id) map[p.id] = p.nome || p.email || p.cpf || p.id;
const patients = await patientService.list();
const map: Record<string, string> = {};
for (const p of patients) {
if (p?.id) {
map[p.id] = p.full_name || p.email || p.cpf || p.id;
}
setPatientsById(map);
} else if (
res &&
typeof (res as unknown) === "object" &&
(
res as {
data?: {
data?: Array<{
id?: string;
nome?: string;
email?: string;
cpf?: string;
}>;
};
}
).data?.data
) {
const list =
(
res as {
data?: {
data?: Array<{
id?: string;
nome?: string;
email?: string;
cpf?: string;
}>;
};
}
).data?.data || [];
const map: Record<string, string> = {};
for (const p of list) {
if (p?.id) map[p.id] = p.nome || p.email || p.cpf || p.id;
}
setPatientsById(map);
}
setPatientsById(map);
} catch {
// silencioso; não bloqueia calendário
} finally {
/* no-op */
}
}

View File

@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { exceptionService } from "../../services";
import { exceptionsService } from "../../services/index";
import type {
DoctorException,
ExceptionKind,
} from "../../services/exceptionService";
} from "../../services/exceptions/types";
interface Props {
doctorId: string;
@ -25,10 +25,15 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
async function load() {
if (!doctorId) return;
setLoading(true);
const res = await exceptionService.listExceptions({ doctor_id: doctorId });
if (res.success && res.data) setList(res.data);
else toast.error(res.error || "Erro ao carregar exceções");
setLoading(false);
try {
const exceptions = await exceptionsService.list({ doctor_id: doctorId });
setList(exceptions);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
toast.error("Erro ao carregar exceções");
} finally {
setLoading(false);
}
}
useEffect(() => {
@ -43,16 +48,15 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
return;
}
setSaving(true);
const res = await exceptionService.createException({
doctor_id: doctorId,
date: form.date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
kind: form.kind,
reason: form.reason || undefined,
});
setSaving(false);
if (res.success) {
try {
await exceptionsService.create({
doctor_id: doctorId,
date: form.date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
kind: form.kind,
reason: form.reason || undefined,
});
toast.success("Exceção criada");
setForm({
date: "",
@ -62,18 +66,26 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
reason: "",
});
void load();
} else toast.error(res.error || "Falha ao criar");
} catch (error) {
console.error("Falha ao criar exceção:", error);
toast.error("Falha ao criar");
} finally {
setSaving(false);
}
}
async function remove(item: DoctorException) {
if (!item.id) return;
const ok = confirm("Remover exceção?");
if (!ok) return;
const res = await exceptionService.deleteException(item.id);
if (res.success) {
try {
await exceptionsService.delete(item.id);
toast.success("Removida");
void load();
} else toast.error(res.error || "Falha ao remover");
} catch (error) {
console.error("Falha ao remover exceção:", error);
toast.error("Falha ao remover");
}
}
return (

View File

@ -8,10 +8,13 @@ import {
X,
} from "lucide-react";
import toast from "react-hot-toast";
import { appointmentService } from "../../services";
import medicoService, { type Medico } from "../../services/medicoService";
import pacienteService from "../../services/pacienteService";
import type { Paciente as PacienteModel } from "../../services/pacienteService";
import {
appointmentService,
doctorService,
patientService,
} from "../../services/index";
import type { Patient } from "../../services/patients/types";
import type { Doctor } from "../../services/doctors/types";
import AvailableSlotsPicker from "./AvailableSlotsPicker";
interface Props {
@ -29,9 +32,9 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
patientName,
onSuccess,
}) => {
const [doctors, setDoctors] = useState<Medico[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [patients, setPatients] = useState<PacienteModel[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
@ -79,37 +82,27 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
async function loadDoctors() {
setLoadingDoctors(true);
const res = await medicoService.listarMedicos();
setLoadingDoctors(false);
if (res.success && res.data) {
setDoctors(res.data.data); // res.data é MedicoListResponse, res.data.data é Medico[]
} else {
try {
const doctors = await doctorService.list();
setDoctors(doctors);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
} finally {
setLoadingDoctors(false);
}
}
async function loadPatients() {
setLoadingPatients(true);
try {
const res = await pacienteService.listPatients();
setLoadingPatients(false);
if (res && Array.isArray(res.data)) {
setPatients(res.data);
} else if (
res &&
typeof (res as unknown) === "object" &&
(res as { data?: { data?: PacienteModel[] } }).data?.data
) {
// fallback caso formato mude (evita any explícito)
setPatients(
(res as { data?: { data?: PacienteModel[] } }).data?.data || []
);
} else {
toast.error("Erro ao carregar pacientes");
}
} catch {
setLoadingPatients(false);
const patients = await patientService.list();
setPatients(patients);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
} finally {
setLoadingPatients(false);
}
}
@ -131,22 +124,23 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
const datetime = `${selectedDate}T${selectedTime}:00`;
const res = await appointmentService.createAppointment({
patient_id: finalPatientId,
doctor_id: selectedDoctorId,
scheduled_at: datetime,
appointment_type: appointmentType,
chief_complaint: reason || undefined,
});
try {
await appointmentService.create({
patient_id: finalPatientId,
doctor_id: selectedDoctorId,
scheduled_at: datetime,
appointment_type: appointmentType,
chief_complaint: reason || undefined,
});
setLoading(false);
if (res.success) {
toast.success("Agendamento criado com sucesso!");
onSuccess?.();
handleClose();
} else {
toast.error(res.error || "Erro ao criar agendamento");
} catch (error) {
console.error("Erro ao criar agendamento:", error);
toast.error("Erro ao criar agendamento");
} finally {
setLoading(false);
}
}
@ -168,7 +162,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
const effectivePatientName = patientPreselected
? patientName
: selectedPatientName ||
(patients.find((p) => p.id === selectedPatientId)?.nome ?? "");
(patients.find((p) => p.id === selectedPatientId)?.full_name ?? "");
// UX: handlers para ESC e clique fora
function onKeyDown(e: React.KeyboardEvent) {
@ -249,7 +243,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
onChange={(e) => {
setSelectedPatientId(e.target.value);
const p = patients.find((px) => px.id === e.target.value);
setSelectedPatientName(p?.nome || "");
setSelectedPatientName(p?.full_name || "");
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
@ -257,7 +251,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
<option value="">-- Selecione um paciente --</option>
{patients.map((p) => (
<option key={p.id} value={p.id}>
{p.nome} {p.cpf ? `- ${p.cpf}` : ""}
{p.full_name} {p.cpf ? `- ${p.cpf}` : ""}
</option>
))}
</select>
@ -289,7 +283,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
<option value="">-- Selecione um médico --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.nome} - {doc.especialidade}
{doc.full_name} - {doc.specialty}
</option>
))}
</select>

View File

@ -1,14 +1,25 @@
import React, { useEffect, useState, useCallback } from "react";
import { X, Loader2 } from "lucide-react";
import consultasService, {
Consulta,
ConsultaCreate,
ConsultaUpdate,
} from "../../services/consultasService";
import { listPatients, Paciente } from "../../services/pacienteService";
import { medicoService, Medico } from "../../services/medicoService";
import {
appointmentService,
patientService,
doctorService,
type Appointment,
type Patient,
type Doctor,
} from "../../services";
import { useAuth } from "../../hooks/useAuth";
// Type aliases para compatibilidade com código antigo
type Consulta = Appointment & {
pacienteId?: string;
medicoId?: string;
dataHora?: string;
observacoes?: string;
};
type Paciente = Patient;
type Medico = Doctor;
interface ConsultaModalProps {
isOpen: boolean;
onClose: () => void;
@ -62,22 +73,13 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
(async () => {
try {
setLoadingLists(true);
const [pacsResp, medsResp] = await Promise.all([
listPatients().catch(() => ({
data: [],
total: 0,
page: 1,
per_page: 0,
})),
medicoService
.listarMedicos()
.catch(() => ({ success: false, data: undefined })),
const [patients, doctors] = await Promise.all([
patientService.list().catch(() => []),
doctorService.list().catch(() => []),
]);
if (!active) return;
setPacientes(pacsResp.data);
if (medsResp && medsResp.success && medsResp.data) {
setMedicos(medsResp.data.data);
}
setPacientes(patients);
setMedicos(doctors);
} finally {
if (active) setLoadingLists(false);
}

View File

@ -1,7 +1,18 @@
import { useState, useContext } from "react";
import { useContext } from "react";
import AuthContext from "../../context/AuthContext";
import React from "react";
import type { EnderecoPaciente } from "../../services/pacienteService";
import { AvatarUpload } from "../ui/AvatarUpload";
// Address interface for patient form
interface EnderecoPaciente {
cep: string;
rua: string;
numero: string;
complemento?: string;
bairro: string;
cidade: string;
estado: string;
}
export interface PacienteFormData {
id?: string;
@ -66,56 +77,11 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
onCancel,
onSubmit,
}) => {
// Avatar upload/remover state
const [avatarEditMode, setAvatarEditMode] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
// Obtem role do usuário autenticado
const auth = useContext(AuthContext);
const canEditAvatar = ["secretaria", "admin", "gestor"].includes(auth?.user?.role || "");
// Função para upload do avatar
const handleAvatarUpload = async () => {
if (!avatarFile || !data.id) return;
setAvatarLoading(true);
const formData = new FormData();
formData.append("file", avatarFile);
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/avatars/${data.id}/avatar`, {
method: "POST",
body: formData,
});
// Atualiza avatar_url no perfil
const ext = avatarFile.name.split(".").pop();
const publicUrl = `https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/public/avatars/${data.id}/avatar.${ext}`;
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/profiles?id=eq.${data.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: publicUrl }),
});
onChange({ avatar_url: publicUrl });
setAvatarEditMode(false);
setAvatarFile(null);
setAvatarLoading(false);
};
// Função para remover avatar
const handleAvatarRemove = async () => {
if (!data.id) return;
setAvatarLoading(true);
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/avatars/${data.id}/avatar`, {
method: "DELETE",
});
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/profiles?id=eq.${data.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: null }),
});
onChange({ avatar_url: undefined });
setAvatarEditMode(false);
setAvatarFile(null);
setAvatarLoading(false);
};
const canEditAvatar = ["secretaria", "admin", "gestor"].includes(
auth?.user?.role || ""
);
return (
<form
@ -124,57 +90,30 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
noValidate
aria-describedby={cpfError ? "cpf-error" : undefined}
>
{/* Bloco do avatar antes do título dos dados pessoais */}
<div className="flex items-center gap-4 mb-6">
<div className="relative group">
{data.avatar_url ? (
<img
src={data.avatar_url}
alt={data.nome}
className="h-16 w-16 rounded-full object-cover border shadow"
/>
) : (
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-blue-700 to-blue-400 flex items-center justify-center text-white font-semibold text-lg shadow">
{data.nome
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
)}
{canEditAvatar && (
<button
type="button"
className="absolute bottom-0 right-0 bg-white rounded-full p-1 border shadow group-hover:bg-blue-100 transition"
title="Editar avatar"
onClick={() => setAvatarEditMode(true)}
style={{ lineHeight: 0 }}
disabled={avatarLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" className="text-blue-600" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828a2 2 0 01-2.828 0L9 13zm0 0V17h4" /></svg>
</button>
)}
{avatarEditMode && canEditAvatar && (
<div className="absolute top-0 left-20 bg-white p-2 rounded shadow z-10 flex flex-col items-center">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={e => setAvatarFile(e.target.files?.[0] || null)}
className="mb-2"
disabled={avatarLoading}
/>
<button type="button" className="text-xs bg-blue-600 text-white px-2 py-1 rounded" onClick={handleAvatarUpload} disabled={avatarLoading}>Salvar</button>
<button type="button" className="text-xs ml-2" onClick={() => setAvatarEditMode(false)} disabled={avatarLoading}>Cancelar</button>
{data.avatar_url && (
<button type="button" className="text-xs text-red-600 underline mt-2" onClick={handleAvatarRemove} disabled={avatarLoading}>Remover</button>
)}
</div>
)}
{/* Avatar com upload */}
<div className="flex items-start gap-4 mb-6 pb-6 border-b border-gray-200">
<AvatarUpload
userId={data.id}
currentAvatarUrl={data.avatar_url}
name={data.nome || "Paciente"}
color="blue"
size="xl"
editable={canEditAvatar && !!data.id}
onAvatarUpdate={(avatarUrl) => {
onChange({ avatar_url: avatarUrl || undefined });
}}
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">
{data.nome || "Novo Paciente"}
</h3>
{data.cpf && <p className="text-sm text-gray-500">CPF: {data.cpf}</p>}
{data.email && <p className="text-sm text-gray-500">{data.email}</p>}
</div>
</div>
{/* Todos os campos do formulário já estão dentro do <form> abaixo do avatar */}
{/* Os campos do formulário devem continuar aqui, dentro do <form> */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-green-600">
Dados pessoais

View File

@ -0,0 +1,84 @@
import { Calendar } from "lucide-react";
import DoctorCalendar from "../agenda/DoctorCalendar";
import AvailabilityManager from "../agenda/AvailabilityManager";
import ExceptionsManager from "../agenda/ExceptionsManager";
interface Medico {
id: string;
nome: string;
}
interface AgendaSectionProps {
medicos: Medico[];
selectedDoctorId: string | null;
onSelectDoctor: (doctorId: string) => void;
}
export default function AgendaSection({
medicos,
selectedDoctorId,
onSelectDoctor,
}: AgendaSectionProps) {
return (
<section className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">
Gerenciar Agenda Médica
</h1>
<p className="text-muted-foreground">
Configure disponibilidades, exceções e visualize o calendário dos
médicos
</p>
</div>
{/* Doctor Selector */}
<div className="bg-card rounded-lg border border-border p-6">
<label className="block text-sm font-medium text-foreground mb-2">
Selecione um médico para gerenciar sua agenda:
</label>
{medicos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum médico cadastrado. Adicione médicos na aba "Médicos"
primeiro.
</p>
) : (
<select
value={selectedDoctorId || ""}
onChange={(e) => onSelectDoctor(e.target.value)}
className="w-full md:w-96 h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico.id} value={medico.id}>
{medico.nome}
</option>
))}
</select>
)}
</div>
{/* Calendar and Availability Management */}
{selectedDoctorId ? (
<div className="space-y-6">
<DoctorCalendar doctorId={selectedDoctorId} />
<AvailabilityManager doctorId={selectedDoctorId} />
<ExceptionsManager doctorId={selectedDoctorId} />
</div>
) : (
<div className="bg-card rounded-lg border border-border p-12">
<div className="flex flex-col items-center justify-center text-center">
<Calendar className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
Selecione um médico
</h3>
<p className="text-muted-foreground max-w-md">
Escolha um médico acima para visualizar e gerenciar sua agenda,
disponibilidades e exceções de horários.
</p>
</div>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,296 @@
import { useState } from "react";
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
// Tipo estendido para incluir campos adicionais
interface ConsultaExtended {
id: string;
dataHora?: string;
pacienteNome?: string;
medicoNome?: string;
tipo?: string;
status?: string;
}
interface ConsultasSectionProps {
consultas: ConsultaExtended[];
loading: boolean;
onRefresh: () => void;
onNovaConsulta: () => void;
onDeleteConsulta: (id: string) => void;
onAlterarStatus: (id: string, status: string) => void;
}
export default function ConsultasSection({
consultas,
loading,
onRefresh,
onNovaConsulta,
onDeleteConsulta,
onAlterarStatus,
}: ConsultasSectionProps) {
const [searchTerm, setSearchTerm] = useState("");
const [filtroDataDe, setFiltroDataDe] = useState("");
const [filtroDataAte, setFiltroDataAte] = useState("");
const [filtroStatus, setFiltroStatus] = useState("");
const [filtroPaciente, setFiltroPaciente] = useState("");
const [filtroMedico, setFiltroMedico] = useState("");
const formatDateTimeLocal = (dateStr: string | undefined) => {
if (!dateStr) return "-";
try {
const date = new Date(dateStr);
return date.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
// Filtrar consultas
const consultasFiltradas = consultas.filter((c) => {
// Filtro de busca rápida
if (searchTerm) {
const search = searchTerm.toLowerCase();
const matchPaciente = c.pacienteNome?.toLowerCase().includes(search);
const matchMedico = c.medicoNome?.toLowerCase().includes(search);
const matchTipo = c.tipo?.toLowerCase().includes(search);
if (!matchPaciente && !matchMedico && !matchTipo) return false;
}
// Filtro por data de
if (filtroDataDe && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate < filtroDataDe) return false;
}
// Filtro por data até
if (filtroDataAte && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate > filtroDataAte) return false;
}
// Filtro por status
if (filtroStatus && c.status !== filtroStatus) return false;
// Filtro por paciente
if (
filtroPaciente &&
!c.pacienteNome?.toLowerCase().includes(filtroPaciente.toLowerCase())
) {
return false;
}
// Filtro por médico
if (
filtroMedico &&
!c.medicoNome?.toLowerCase().includes(filtroMedico.toLowerCase())
) {
return false;
}
return true;
});
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Consultas</h1>
<p className="text-muted-foreground">
Gerencie todas as consultas agendadas
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 border border-input hover:bg-accent text-foreground px-4 py-2 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span className="hidden md:inline">Atualizar</span>
</button>
<button
onClick={onNovaConsulta}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Nova Consulta
</button>
</div>
</div>
{/* Search and Filters */}
<div className="bg-card rounded-lg border border-border p-4 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Busca rápida (paciente, médico ou tipo)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 w-full h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data de
</label>
<input
type="date"
value={filtroDataDe}
onChange={(e) => setFiltroDataDe(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data até
</label>
<input
type="date"
value={filtroDataAte}
onChange={(e) => setFiltroDataAte(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Status
</label>
<select
value={filtroStatus}
onChange={(e) => setFiltroStatus(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Todos</option>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Paciente
</label>
<input
value={filtroPaciente}
onChange={(e) => setFiltroPaciente(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar paciente"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Médico
</label>
<input
value={filtroMedico}
onChange={(e) => setFiltroMedico(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar médico"
/>
</div>
</div>
</div>
{/* Appointments Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : consultasFiltradas.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum agendamento encontrado. Use a aba "Agenda" para gerenciar
horários dos médicos.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data/Hora
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Médico
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Tipo
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase w-[140px]">
Ações
</th>
</tr>
</thead>
<tbody>
{consultasFiltradas.map((consulta) => (
<tr
key={consulta.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm text-foreground whitespace-nowrap">
{formatDateTimeLocal(consulta.dataHora)}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.pacienteNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.medicoNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.tipo}
</td>
<td className="p-4">
<select
value={consulta.status}
onChange={(e) =>
consulta.id &&
onAlterarStatus(consulta.id, e.target.value)
}
className="text-sm border border-input rounded-md px-2 py-1 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
<option value="faltou">Faltou</option>
</select>
</td>
<td className="p-4">
<button
onClick={() =>
consulta.id && onDeleteConsulta(consulta.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
>
<Trash2 className="h-4 w-4" />
Excluir
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,181 @@
import { Plus, Eye, Edit2 } from "lucide-react";
interface Relatorio {
id?: string;
order_number?: string;
exam?: string;
patient_id?: string;
status?: string;
created_at?: string;
}
interface Paciente {
id: string;
nome: string;
}
interface RelatoriosSectionProps {
relatorios: Relatorio[];
pacientes: Paciente[];
loading: boolean;
onNovoRelatorio: () => void;
onVerDetalhes: (id: string) => void;
onEditarRelatorio: (id: string) => void;
}
export default function RelatoriosSection({
relatorios,
pacientes,
loading,
onNovoRelatorio,
onVerDetalhes,
onEditarRelatorio,
}: RelatoriosSectionProps) {
const getStatusBadgeClass = (status?: string) => {
switch (status) {
case "draft":
return "bg-muted text-foreground";
case "completed":
return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400";
case "pending":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400";
default:
return "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400";
}
};
const getStatusLabel = (status?: string) => {
switch (status) {
case "draft":
return "Rascunho";
case "completed":
return "Concluído";
case "pending":
return "Pendente";
default:
return "Cancelado";
}
};
const getPacienteNome = (patientId?: string) => {
const paciente = pacientes.find((p) => p.id === patientId);
return paciente?.nome || patientId || "-";
};
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Relatórios</h1>
<p className="text-muted-foreground">
Gerencie relatórios de exames e diagnósticos
</p>
</div>
<button
onClick={onNovoRelatorio}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Novo Relatório
</button>
</div>
{/* Reports Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : relatorios.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum relatório encontrado.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Número
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Exame
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground uppercase w-[200px]">
Ações
</th>
</tr>
</thead>
<tbody>
{relatorios.map((relatorio) => (
<tr
key={relatorio.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm font-medium text-foreground">
{relatorio.order_number || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{relatorio.exam || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{getPacienteNome(relatorio.patient_id)}
</td>
<td className="p-4">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeClass(
relatorio.status
)}`}
>
{getStatusLabel(relatorio.status)}
</span>
</td>
<td className="p-4 text-sm text-muted-foreground">
{relatorio.created_at
? new Date(relatorio.created_at).toLocaleDateString(
"pt-BR"
)
: "-"}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() =>
relatorio.id && onVerDetalhes(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<Eye className="h-4 w-4" />
Ver
</button>
<button
onClick={() =>
relatorio.id && onEditarRelatorio(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-accent/10 text-foreground hover:bg-accent/20 transition-colors"
>
<Edit2 className="h-4 w-4" />
Editar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,351 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Edit, Trash2 } from "lucide-react";
import {
appointmentService,
type Appointment,
patientService,
type Patient,
doctorService,
type Doctor,
} from "../../services";
import { Avatar } from "../ui/Avatar";
interface AppointmentWithDetails extends Appointment {
patient?: Patient;
doctor?: Doctor;
}
export function SecretaryAppointmentList() {
const [appointments, setAppointments] = useState<AppointmentWithDetails[]>(
[]
);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("Todos");
const [typeFilter, setTypeFilter] = useState("Todos");
const loadAppointments = async () => {
setLoading(true);
try {
const data = await appointmentService.list();
// Buscar detalhes de pacientes e médicos
const appointmentsWithDetails = await Promise.all(
(Array.isArray(data) ? data : []).map(async (appointment) => {
try {
const [patient, doctor] = await Promise.all([
appointment.patient_id
? patientService.getById(appointment.patient_id)
: null,
appointment.doctor_id
? doctorService.getById(appointment.doctor_id)
: null,
]);
return {
...appointment,
patient: patient || undefined,
doctor: doctor || undefined,
};
} catch (error) {
console.error("Erro ao carregar detalhes:", error);
return appointment;
}
})
);
setAppointments(appointmentsWithDetails);
console.log("✅ Consultas carregadas:", appointmentsWithDetails);
} catch (error) {
console.error("❌ Erro ao carregar consultas:", error);
toast.error("Erro ao carregar consultas");
setAppointments([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAppointments();
}, []);
const handleSearch = () => {
loadAppointments();
};
const handleClear = () => {
setSearchTerm("");
setStatusFilter("Todos");
setTypeFilter("Todos");
loadAppointments();
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { label: string; className: string }> = {
confirmada: {
label: "Confirmada",
className: "bg-green-100 text-green-700",
},
agendada: { label: "Agendada", className: "bg-blue-100 text-blue-700" },
cancelada: { label: "Cancelada", className: "bg-red-100 text-red-700" },
concluida: { label: "Concluída", className: "bg-gray-100 text-gray-700" },
};
const config = statusMap[status] || {
label: status,
className: "bg-gray-100 text-gray-700",
};
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${config.className}`}
>
{config.label}
</span>
);
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "—";
}
};
const formatTime = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "—";
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Consultas</h1>
<p className="text-gray-600 mt-1">Gerencie as consultas agendadas</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<Plus className="h-4 w-4" />
Nova Consulta
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar consultas por paciente ou médico..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Status:</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Confirmada</option>
<option>Agendada</option>
<option>Cancelada</option>
<option>Concluída</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Tipo:</span>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Presencial</option>
<option>Telemedicina</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Paciente
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Médico
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Data/Hora
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Tipo
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={6}
className="px-6 py-12 text-center text-gray-500"
>
Carregando consultas...
</td>
</tr>
) : appointments.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-12 text-center text-gray-500"
>
Nenhuma consulta encontrada
</td>
</tr>
) : (
appointments.map((appointment) => (
<tr
key={appointment.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={appointment.patient}
name={appointment.patient?.full_name || ""}
size="md"
color="blue"
/>
<div>
<p className="text-sm font-medium text-gray-900">
{appointment.patient?.full_name ||
"Paciente não encontrado"}
</p>
<p className="text-xs text-gray-500">
{appointment.patient?.email || "—"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={appointment.doctor}
name={appointment.doctor?.full_name || ""}
size="md"
color="green"
/>
<div>
<p className="text-sm font-medium text-gray-900">
{appointment.doctor?.full_name ||
"Médico não encontrado"}
</p>
<p className="text-xs text-gray-500">
{appointment.doctor?.specialty || "—"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{appointment.scheduled_at ? (
<>
<div className="font-medium">
{formatDate(appointment.scheduled_at)}
</div>
<div className="text-gray-500 text-xs">
{formatTime(appointment.scheduled_at)}
</div>
</>
) : (
"—"
)}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
appointment.appointment_type === "telemedicina"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{appointment.appointment_type === "telemedicina"
? "Telemedicina"
: "Presencial"}
</span>
</td>
<td className="px-6 py-4">
{getStatusBadge(appointment.status || "agendada")}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Cancelar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,561 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Calendar, Edit, Trash2, X } from "lucide-react";
import {
doctorService,
userService,
type Doctor,
type CrmUF,
} from "../../services";
interface DoctorFormData {
id?: string;
full_name: string;
cpf: string;
email: string;
phone_mobile: string;
crm: string;
crm_uf: string;
specialty: string;
birth_date?: string;
}
const UF_OPTIONS = [
"AC",
"AL",
"AP",
"AM",
"BA",
"CE",
"DF",
"ES",
"GO",
"MA",
"MT",
"MS",
"MG",
"PA",
"PB",
"PR",
"PE",
"PI",
"RJ",
"RN",
"RS",
"RO",
"RR",
"SC",
"SP",
"SE",
"TO",
];
export function SecretaryDoctorList() {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [specialtyFilter, setSpecialtyFilter] = useState("Todas");
// Modal states
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [formData, setFormData] = useState<DoctorFormData>({
full_name: "",
cpf: "",
email: "",
phone_mobile: "",
crm: "",
crm_uf: "",
specialty: "",
});
const loadDoctors = async () => {
setLoading(true);
try {
const data = await doctorService.list();
setDoctors(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
setDoctors([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDoctors();
}, []);
const handleSearch = () => {
loadDoctors();
};
const handleClear = () => {
setSearchTerm("");
setSpecialtyFilter("Todas");
loadDoctors();
};
const handleNewDoctor = () => {
setModalMode("create");
setFormData({
full_name: "",
cpf: "",
email: "",
phone_mobile: "",
crm: "",
crm_uf: "",
specialty: "",
});
setShowModal(true);
};
const handleEditDoctor = (doctor: Doctor) => {
setModalMode("edit");
setFormData({
id: doctor.id,
full_name: doctor.full_name || "",
cpf: doctor.cpf || "",
email: doctor.email || "",
phone_mobile: doctor.phone_mobile || "",
crm: doctor.crm || "",
crm_uf: doctor.crm_uf || "",
specialty: doctor.specialty || "",
birth_date: doctor.birth_date || "",
});
setShowModal(true);
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (modalMode === "edit" && formData.id) {
// Para edição, usa o endpoint antigo (PATCH /doctors/:id)
const doctorData = {
full_name: formData.full_name,
cpf: formData.cpf,
email: formData.email,
phone_mobile: formData.phone_mobile,
crm: formData.crm,
crm_uf: formData.crm_uf as CrmUF,
specialty: formData.specialty,
birth_date: formData.birth_date || null,
};
await doctorService.update(formData.id, doctorData);
toast.success("Médico atualizado com sucesso!");
} else {
// Para criação, usa o novo endpoint create-doctor com validações completas
const createData = {
email: formData.email,
full_name: formData.full_name,
cpf: formData.cpf,
crm: formData.crm,
crm_uf: formData.crm_uf as CrmUF,
specialty: formData.specialty,
phone_mobile: formData.phone_mobile || undefined,
};
await userService.createDoctor(createData);
toast.success("Médico cadastrado com sucesso!");
}
setShowModal(false);
loadDoctors();
} catch (error) {
console.error("Erro ao salvar médico:", error);
toast.error("Erro ao salvar médico");
} finally {
setLoading(false);
}
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getAvatarColor = (index: number) => {
const colors = [
"bg-red-500",
"bg-green-500",
"bg-blue-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
];
return colors[index % colors.length];
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Médicos</h1>
<p className="text-gray-600 mt-1">Gerencie os médicos cadastrados</p>
</div>
<button
onClick={handleNewDoctor}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
Novo Médico
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar médicos por nome ou CRM..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Especialidade:</span>
<select
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todas</option>
<option>Cardiologia</option>
<option>Dermatologia</option>
<option>Ortopedia</option>
<option>Pediatria</option>
<option>Psiquiatria</option>
<option>Ginecologia</option>
</select>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Médico
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Especialidade
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
CRM
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Próxima Disponível
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Carregando médicos...
</td>
</tr>
) : doctors.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Nenhum médico encontrado
</td>
</tr>
) : (
doctors.map((doctor, index) => (
<tr
key={doctor.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-full ${getAvatarColor(
index
)} flex items-center justify-center text-white font-semibold text-sm`}
>
{getInitials(doctor.full_name || "")}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
Dr. {doctor.full_name}
</p>
<p className="text-sm text-gray-500">{doctor.email}</p>
<p className="text-sm text-gray-500">
{doctor.phone_mobile}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{doctor.specialty || "—"}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{doctor.crm || "—"}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{/* TODO: Buscar próxima disponibilidade */}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
title="Gerenciar agenda"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
<Calendar className="h-4 w-4" />
</button>
<button
onClick={() => handleEditDoctor(doctor)}
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal de Formulário */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{modalMode === "create" ? "Novo Médico" : "Editar Médico"}
</h2>
<button
onClick={() => setShowModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form Content */}
<div className="flex-1 overflow-y-auto p-6">
<form onSubmit={handleFormSubmit} className="space-y-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) =>
setFormData({ ...formData, full_name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="Dr. João Silva"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CPF *
</label>
<input
type="text"
value={formData.cpf}
onChange={(e) =>
setFormData({ ...formData, cpf: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="000.000.000-00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data de Nascimento
</label>
<input
type="date"
value={formData.birth_date || ""}
onChange={(e) =>
setFormData({
...formData,
birth_date: e.target.value,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CRM *
</label>
<input
type="text"
value={formData.crm}
onChange={(e) =>
setFormData({ ...formData, crm: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="123456"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
UF do CRM *
</label>
<select
value={formData.crm_uf}
onChange={(e) =>
setFormData({ ...formData, crm_uf: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
>
<option value="">Selecione</option>
{UF_OPTIONS.map((uf) => (
<option key={uf} value={uf}>
{uf}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Especialidade *
</label>
<select
value={formData.specialty}
onChange={(e) =>
setFormData({ ...formData, specialty: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
>
<option value="">Selecione</option>
<option value="Cardiologia">Cardiologia</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Ortopedia">Ortopedia</option>
<option value="Pediatria">Pediatria</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Ginecologia">Ginecologia</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="medico@exemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Telefone *
</label>
<input
type="tel"
value={formData.phone_mobile}
onChange={(e) =>
setFormData({
...formData,
phone_mobile: e.target.value,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="(00) 00000-0000"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{loading ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,526 @@
import { useState, useEffect, useCallback } from "react";
import toast from "react-hot-toast";
import {
ChevronLeft,
ChevronRight,
Plus,
Edit,
Trash2,
Calendar as CalendarIcon,
} from "lucide-react";
import {
doctorService,
appointmentService,
availabilityService,
type Doctor,
type Appointment,
type DoctorAvailability,
} from "../../services";
interface DayCell {
date: Date;
isCurrentMonth: boolean;
appointments: Appointment[];
}
export function SecretaryDoctorSchedule() {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [selectedDoctorId, setSelectedDoctorId] = useState<string>("");
const [currentDate, setCurrentDate] = useState(new Date());
const [calendarDays, setCalendarDays] = useState<DayCell[]>([]);
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>(
[]
);
const [loading, setLoading] = useState(false);
// Modal states
const [showAvailabilityDialog, setShowAvailabilityDialog] = useState(false);
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
// Availability form
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
const [startTime, setStartTime] = useState("08:00");
const [endTime, setEndTime] = useState("18:00");
const [duration, setDuration] = useState(30);
// Exception form
const [exceptionType, setExceptionType] = useState("férias");
const [exceptionStartDate, setExceptionStartDate] = useState("");
const [exceptionEndDate, setExceptionEndDate] = useState("");
const [exceptionReason, setExceptionReason] = useState("");
useEffect(() => {
loadDoctors();
}, []);
const loadDoctorSchedule = useCallback(async () => {
if (!selectedDoctorId) return;
setLoading(true);
try {
// Load availabilities
const availData = await availabilityService.list({
doctor_id: selectedDoctorId,
});
setAvailabilities(Array.isArray(availData) ? availData : []);
// Load appointments for the month (will be used for calendar display)
await appointmentService.list();
} catch (error) {
console.error("Erro ao carregar agenda:", error);
toast.error("Erro ao carregar agenda do médico");
} finally {
setLoading(false);
}
}, [selectedDoctorId]);
useEffect(() => {
loadDoctorSchedule();
}, [loadDoctorSchedule]);
const generateCalendar = useCallback(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const days: DayCell[] = [];
const currentDatePointer = new Date(startDate);
for (let i = 0; i < 42; i++) {
days.push({
date: new Date(currentDatePointer),
isCurrentMonth: currentDatePointer.getMonth() === month,
appointments: [],
});
currentDatePointer.setDate(currentDatePointer.getDate() + 1);
}
setCalendarDays(days);
}, [currentDate]);
useEffect(() => {
generateCalendar();
}, [generateCalendar]);
const loadDoctors = async () => {
try {
const data = await doctorService.list();
setDoctors(Array.isArray(data) ? data : []);
if (data.length > 0) {
setSelectedDoctorId(data[0].id);
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
}
};
const previousMonth = () => {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1)
);
};
const nextMonth = () => {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1)
);
};
const goToToday = () => {
setCurrentDate(new Date());
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("pt-BR", { month: "long", year: "numeric" });
};
const handleAddAvailability = async () => {
if (selectedWeekdays.length === 0) {
toast.error("Selecione pelo menos um dia da semana");
return;
}
try {
// TODO: Implement availability creation
toast.success("Disponibilidade adicionada com sucesso");
setShowAvailabilityDialog(false);
loadDoctorSchedule();
} catch (error) {
console.error("Erro ao adicionar disponibilidade:", error);
toast.error("Erro ao adicionar disponibilidade");
}
};
const handleAddException = async () => {
if (!exceptionStartDate || !exceptionEndDate) {
toast.error("Preencha as datas de início e fim");
return;
}
try {
// TODO: Implement exception creation
toast.success("Exceção adicionada com sucesso");
setShowExceptionDialog(false);
loadDoctorSchedule();
} catch (error) {
console.error("Erro ao adicionar exceção:", error);
toast.error("Erro ao adicionar exceção");
}
};
const weekdays = [
{ value: "monday", label: "Segunda" },
{ value: "tuesday", label: "Terça" },
{ value: "wednesday", label: "Quarta" },
{ value: "thursday", label: "Quinta" },
{ value: "friday", label: "Sexta" },
{ value: "saturday", label: "Sábado" },
{ value: "sunday", label: "Domingo" },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agenda Médica</h1>
<p className="text-gray-600 mt-1">
Gerencie disponibilidades e exceções
</p>
</div>
</div>
{/* Doctor Selector */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Selecione o Médico
</label>
<select
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{doctors.map((doctor) => (
<option key={doctor.id} value={doctor.id}>
Dr. {doctor.full_name} - {doctor.specialty}
</option>
))}
</select>
</div>
{/* Calendar */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900 capitalize">
{formatMonthYear(currentDate)}
</h2>
<div className="flex items-center gap-2">
<button
onClick={goToToday}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Hoje
</button>
<button
onClick={previousMonth}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={nextMonth}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div
key={day}
className="bg-gray-50 px-2 py-3 text-center text-sm font-semibold text-gray-700"
>
{day}
</div>
))}
{calendarDays.map((day, index) => (
<div
key={index}
className={`bg-white p-2 min-h-[80px] ${
day.isCurrentMonth ? "" : "opacity-40"
} ${
day.date.toDateString() === new Date().toDateString()
? "bg-blue-50"
: ""
}`}
>
<div className="text-sm text-gray-700 mb-1">
{day.date.getDate()}
</div>
{day.appointments.map((apt, i) => (
<div
key={i}
className="text-xs bg-green-100 text-green-800 p-1 rounded mb-1 truncate"
>
{apt.patient_id}
</div>
))}
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => setShowAvailabilityDialog(true)}
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-5 w-5" />
Adicionar Disponibilidade
</button>
<button
onClick={() => setShowExceptionDialog(true)}
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
<CalendarIcon className="h-5 w-5" />
Adicionar Exceção (Férias/Bloqueio)
</button>
</div>
{/* Current Availability */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Disponibilidade Atual
</h3>
{loading ? (
<p className="text-gray-500">Carregando...</p>
) : availabilities.length === 0 ? (
<p className="text-gray-500">Nenhuma disponibilidade configurada</p>
) : (
<div className="space-y-3">
{availabilities.map((avail) => (
<div
key={avail.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
{avail.day_of_week}
</p>
<p className="text-sm text-gray-600">
{avail.start_time} - {avail.end_time}
</p>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700">
Ativo
</span>
<button
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Availability Dialog */}
{showAvailabilityDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Adicionar Disponibilidade
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dias da Semana
</label>
<div className="space-y-2">
{weekdays.map((day) => (
<label
key={day.value}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
checked={selectedWeekdays.includes(day.value)}
onChange={(e) => {
if (e.target.checked) {
setSelectedWeekdays([
...selectedWeekdays,
day.value,
]);
} else {
setSelectedWeekdays(
selectedWeekdays.filter((d) => d !== day.value)
);
}
}}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">{day.label}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora Início
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora Fim
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Duração da Consulta (minutos)
</label>
<input
type="number"
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowAvailabilityDialog(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAddAvailability}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)}
{/* Exception Dialog */}
{showExceptionDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Adicionar Exceção
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Exceção
</label>
<select
value={exceptionType}
onChange={(e) => setExceptionType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
>
<option value="férias">Férias</option>
<option value="licença">Licença Médica</option>
<option value="congresso">Congresso</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data Início
</label>
<input
type="date"
value={exceptionStartDate}
onChange={(e) => setExceptionStartDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data Fim
</label>
<input
type="date"
value={exceptionEndDate}
onChange={(e) => setExceptionEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo (Opcional)
</label>
<input
type="text"
value={exceptionReason}
onChange={(e) => setExceptionReason(e.target.value)}
placeholder="Ex: Férias anuais, Conferência médica..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowExceptionDialog(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAddException}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,513 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Calendar, Edit, Trash2, X } from "lucide-react";
import { patientService, userService, type Patient } from "../../services";
import PacienteForm, { type PacienteFormData } from "../pacientes/PacienteForm";
import { Avatar } from "../ui/Avatar";
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const CONVENIOS = [
"Particular",
"Unimed",
"Amil",
"Bradesco Saúde",
"SulAmérica",
"Golden Cross",
];
const COUNTRY_OPTIONS = [
{ value: "55", label: "+55 🇧🇷 Brasil" },
{ value: "1", label: "+1 🇺🇸 EUA/Canadá" },
];
// Função para buscar endereço via CEP
const buscarEnderecoViaCEP = async (cep: string) => {
try {
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await response.json();
if (data.erro) return null;
return {
rua: data.logradouro,
bairro: data.bairro,
cidade: data.localidade,
estado: data.uf,
cep: data.cep,
};
} catch {
return null;
}
};
export function SecretaryPatientList() {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [insuranceFilter, setInsuranceFilter] = useState("Todos");
const [showBirthdays, setShowBirthdays] = useState(false);
const [showVIP, setShowVIP] = useState(false);
// Modal states
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [formData, setFormData] = useState<PacienteFormData>({
nome: "",
social_name: "",
cpf: "",
sexo: "",
dataNascimento: "",
email: "",
codigoPais: "55",
ddd: "",
numeroTelefone: "",
tipo_sanguineo: "",
altura: "",
peso: "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: "",
rua: "",
numero: "",
bairro: "",
cidade: "",
estado: "",
},
});
const [cpfError, setCpfError] = useState<string | null>(null);
const [cpfValidationMessage, setCpfValidationMessage] = useState<
string | null
>(null);
const loadPatients = async () => {
setLoading(true);
try {
const data = await patientService.list();
console.log("✅ Pacientes carregados:", data);
setPatients(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum paciente encontrado na API");
}
} catch (error) {
console.error("❌ Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
setPatients([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPatients();
}, []);
const handleSearch = () => {
loadPatients();
};
const handleClear = () => {
setSearchTerm("");
setInsuranceFilter("Todos");
setShowBirthdays(false);
setShowVIP(false);
loadPatients();
};
const handleNewPatient = () => {
setModalMode("create");
setFormData({
nome: "",
social_name: "",
cpf: "",
sexo: "",
dataNascimento: "",
email: "",
codigoPais: "55",
ddd: "",
numeroTelefone: "",
tipo_sanguineo: "",
altura: "",
peso: "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: "",
rua: "",
numero: "",
bairro: "",
cidade: "",
estado: "",
},
});
setCpfError(null);
setCpfValidationMessage(null);
setShowModal(true);
};
const handleEditPatient = (patient: Patient) => {
setModalMode("edit");
setFormData({
id: patient.id,
nome: patient.full_name || "",
social_name: patient.social_name || "",
cpf: patient.cpf || "",
sexo: patient.sex || "",
dataNascimento: patient.birth_date || "",
email: patient.email || "",
codigoPais: "55",
ddd: "",
numeroTelefone: patient.phone_mobile || "",
tipo_sanguineo: patient.blood_type || "",
altura: patient.height_m?.toString() || "",
peso: patient.weight_kg?.toString() || "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: patient.cep || "",
rua: patient.street || "",
numero: patient.number || "",
complemento: patient.complement || "",
bairro: patient.neighborhood || "",
cidade: patient.city || "",
estado: patient.state || "",
},
});
setCpfError(null);
setCpfValidationMessage(null);
setShowModal(true);
};
const handleFormChange = (patch: Partial<PacienteFormData>) => {
setFormData((prev) => ({ ...prev, ...patch }));
};
const handleCpfChange = (value: string) => {
setFormData((prev) => ({ ...prev, cpf: value }));
setCpfError(null);
setCpfValidationMessage(null);
};
const handleCepLookup = async (cep: string) => {
const endereco = await buscarEnderecoViaCEP(cep);
if (endereco) {
setFormData((prev) => ({
...prev,
endereco: {
...prev.endereco,
...endereco,
},
}));
toast.success("Endereço encontrado!");
} else {
toast.error("CEP não encontrado");
}
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (modalMode === "edit" && formData.id) {
// Para edição, usa o endpoint antigo (PATCH /patients/:id)
const patientData = {
full_name: formData.nome,
social_name: formData.social_name || null,
cpf: formData.cpf,
sex: formData.sexo || null,
birth_date: formData.dataNascimento || null,
email: formData.email,
phone_mobile: formData.numeroTelefone,
blood_type: formData.tipo_sanguineo || null,
height_m: formData.altura ? parseFloat(formData.altura) : null,
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
cep: formData.endereco.cep || null,
street: formData.endereco.rua || null,
number: formData.endereco.numero || null,
complement: formData.endereco.complemento || null,
neighborhood: formData.endereco.bairro || null,
city: formData.endereco.cidade || null,
state: formData.endereco.estado || null,
};
await patientService.update(formData.id, patientData);
toast.success("Paciente atualizado com sucesso!");
} else {
// Para criação, usa o novo endpoint create-patient com validações completas
const createData = {
email: formData.email,
full_name: formData.nome,
cpf: formData.cpf,
phone_mobile: formData.numeroTelefone,
birth_date: formData.dataNascimento || undefined,
address: formData.endereco.rua
? `${formData.endereco.rua}${
formData.endereco.numero ? ", " + formData.endereco.numero : ""
}${
formData.endereco.bairro ? " - " + formData.endereco.bairro : ""
}${
formData.endereco.cidade ? " - " + formData.endereco.cidade : ""
}${
formData.endereco.estado ? "/" + formData.endereco.estado : ""
}`
: undefined,
};
await userService.createPatient(createData);
toast.success("Paciente cadastrado com sucesso!");
}
setShowModal(false);
loadPatients();
} catch (error) {
console.error("Erro ao salvar paciente:", error);
toast.error("Erro ao salvar paciente");
} finally {
setLoading(false);
}
};
const handleCancelForm = () => {
setShowModal(false);
};
const getPatientColor = (
index: number
): "blue" | "green" | "purple" | "orange" | "pink" | "teal" => {
const colors: Array<
"blue" | "green" | "purple" | "orange" | "pink" | "teal"
> = ["blue", "green", "purple", "orange", "pink", "teal"];
return colors[index % colors.length];
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Pacientes</h1>
<p className="text-gray-600 mt-1">
Gerencie os pacientes cadastrados
</p>
</div>
<button
onClick={handleNewPatient}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
Novo Paciente
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar pacientes por nome ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showBirthdays}
onChange={(e) => setShowBirthdays(e.target.checked)}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">
Aniversariantes do mês
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showVIP}
onChange={(e) => setShowVIP(e.target.checked)}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Somente VIP</span>
</label>
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-gray-600">Convênio:</span>
<select
value={insuranceFilter}
onChange={(e) => setInsuranceFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Particular</option>
<option>Unimed</option>
<option>Amil</option>
<option>Bradesco Saúde</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Paciente
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Próximo Atendimento
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Convênio
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={4}
className="px-6 py-12 text-center text-gray-500"
>
Carregando pacientes...
</td>
</tr>
) : patients.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-6 py-12 text-center text-gray-500"
>
Nenhum paciente encontrado
</td>
</tr>
) : (
patients.map((patient, index) => (
<tr
key={patient.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={patient}
name={patient.full_name || ""}
size="md"
color={getPatientColor(index)}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{patient.full_name}
</p>
<p className="text-sm text-gray-500">{patient.email}</p>
<p className="text-sm text-gray-500">
{patient.phone_mobile}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{/* TODO: Buscar próximo agendamento */}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
Particular
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
title="Agendar consulta"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
<Calendar className="h-4 w-4" />
</button>
<button
onClick={() => handleEditPatient(patient)}
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal de Formulário */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{modalMode === "create" ? "Novo Paciente" : "Editar Paciente"}
</h2>
<button
onClick={handleCancelForm}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form Content */}
<div className="flex-1 overflow-y-auto p-6">
<PacienteForm
mode={modalMode}
loading={loading}
data={formData}
bloodTypes={BLOOD_TYPES}
convenios={CONVENIOS}
countryOptions={COUNTRY_OPTIONS}
cpfError={cpfError}
cpfValidationMessage={cpfValidationMessage}
onChange={handleFormChange}
onCpfChange={handleCpfChange}
onCepLookup={handleCepLookup}
onCancel={handleCancelForm}
onSubmit={handleFormSubmit}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,247 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, FileText, Download } from "lucide-react";
import { reportService, type Report } from "../../services";
export function SecretaryReportList() {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("Todos");
const [periodFilter, setPeriodFilter] = useState("Todos");
useEffect(() => {
loadReports();
}, []);
const loadReports = async () => {
setLoading(true);
try {
const data = await reportService.list();
console.log("✅ Relatórios carregados:", data);
setReports(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum relatório encontrado na API");
}
} catch (error) {
console.error("❌ Erro ao carregar relatórios:", error);
toast.error("Erro ao carregar relatórios");
setReports([]);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
loadReports();
};
const handleClear = () => {
setSearchTerm("");
setTypeFilter("Todos");
setPeriodFilter("Todos");
loadReports();
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "—";
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Relatórios</h1>
<p className="text-gray-600 mt-1">
Visualize e baixe relatórios do sistema
</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<FileText className="h-4 w-4" />
Gerar Relatório
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar relatórios..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Tipo:</span>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Financeiro</option>
<option>Atendimentos</option>
<option>Pacientes</option>
<option>Médicos</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Período:</span>
<select
value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Hoje</option>
<option>Esta Semana</option>
<option>Este Mês</option>
<option>Este Ano</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Relatório
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Criado Em
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Solicitante
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Carregando relatórios...
</td>
</tr>
) : reports.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Nenhum relatório encontrado
</td>
</tr>
) : (
reports.map((report) => (
<tr
key={report.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{report.order_number}
</p>
<p className="text-xs text-gray-500">
{report.exam || "Sem exame"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
report.status === "completed"
? "bg-green-100 text-green-800"
: report.status === "pending"
? "bg-yellow-100 text-yellow-800"
: report.status === "draft"
? "bg-gray-100 text-gray-800"
: "bg-red-100 text-red-800"
}`}
>
{report.status === "completed"
? "Concluído"
: report.status === "pending"
? "Pendente"
: report.status === "draft"
? "Rascunho"
: "Cancelado"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{formatDate(report.created_at)}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{report.requested_by || "—"}
</td>
<td className="px-6 py-4">
<button
title="Baixar"
disabled={report.status !== "completed"}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors ${
report.status === "completed"
? "text-green-600 hover:bg-green-50"
: "text-gray-400 cursor-not-allowed"
}`}
>
<Download className="h-4 w-4" />
<span className="text-sm font-medium">Baixar</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { SecretaryPatientList } from "./SecretaryPatientList";
export { SecretaryDoctorList } from "./SecretaryDoctorList";
export { SecretaryAppointmentList } from "./SecretaryAppointmentList";
export { SecretaryDoctorSchedule } from "./SecretaryDoctorSchedule";
export { SecretaryReportList } from "./SecretaryReportList";

View File

@ -0,0 +1,158 @@
import { useState, useEffect } from "react";
import { User } from "lucide-react";
interface AvatarProps {
/** URL do avatar, objeto com avatar_url, ou userId para buscar */
src?:
| string
| { avatar_url?: string | null }
| { profile?: { avatar_url?: string | null } }
| { id?: string };
/** Nome completo para gerar iniciais */
name?: string;
/** Tamanho do avatar */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** Cor do gradiente (se não tiver imagem) */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Classe CSS adicional */
className?: string;
/** Se deve mostrar borda */
border?: boolean;
}
const sizeClasses = {
xs: "w-6 h-6 text-xs",
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
xl: "w-16 h-16 text-xl",
};
const colorClasses = {
blue: "from-blue-400 to-blue-600",
green: "from-green-400 to-green-600",
purple: "from-purple-400 to-purple-600",
orange: "from-orange-400 to-orange-600",
pink: "from-pink-400 to-pink-600",
teal: "from-teal-400 to-teal-600",
indigo: "from-indigo-400 to-indigo-600",
red: "from-red-400 to-red-600",
};
/**
* Componente Avatar
* - Mostra imagem se disponível
* - Mostra iniciais como fallback
* - Suporta diferentes tamanhos e cores
*/
export function Avatar({
src,
name = "",
size = "md",
color = "blue",
className = "",
border = false,
}: AvatarProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
// Extrai URL do avatar
useEffect(() => {
if (!src) {
setImageUrl(null);
return;
}
if (typeof src === "string") {
setImageUrl(src);
} else if ("avatar_url" in src && src.avatar_url) {
setImageUrl(src.avatar_url);
} else if ("profile" in src && src.profile?.avatar_url) {
setImageUrl(src.profile.avatar_url);
} else if ("id" in src && src.id) {
// Gera URL pública do Supabase Storage
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
setImageUrl(
`${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar`
);
} else {
setImageUrl(null);
}
setImageError(false);
}, [src]);
// Gera iniciais do nome
const getInitials = (fullName: string): string => {
if (!fullName) return "?";
const parts = fullName.trim().split(" ");
if (parts.length === 1) {
return parts[0].substring(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const initials = getInitials(name);
const shouldShowImage = imageUrl && !imageError;
return (
<div
className={`
${sizeClasses[size]}
rounded-full
flex items-center justify-center
overflow-hidden
${border ? "ring-2 ring-white shadow-lg" : ""}
${
shouldShowImage
? "bg-gray-100"
: `bg-gradient-to-br ${colorClasses[color]}`
}
${className}
`}
>
{shouldShowImage ? (
<img
src={imageUrl}
alt={name || "Avatar"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<span className="text-white font-semibold select-none">{initials}</span>
)}
</div>
);
}
/**
* Avatar com ícone padrão (para casos sem nome)
*/
export function AvatarIcon({
size = "md",
className = "",
}: Pick<AvatarProps, "size" | "className">) {
return (
<div
className={`
${sizeClasses[size]}
rounded-full
bg-gray-200
flex items-center justify-center
${className}
`}
>
<User className="w-1/2 h-1/2 text-gray-500" />
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState, useRef, useEffect } from "react";
import { Camera, Upload, X, Trash2 } from "lucide-react";
import { avatarService, profileService } from "../../services";
import toast from "react-hot-toast";
import { Avatar } from "./Avatar";
interface AvatarUploadProps {
/** ID do usuário */
userId?: string;
/** URL atual do avatar */
currentAvatarUrl?: string;
/** Nome para gerar iniciais */
name?: string;
/** Cor do avatar */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Tamanho do avatar */
size?: "lg" | "xl";
/** Callback quando o avatar é atualizado */
onAvatarUpdate?: (avatarUrl: string | null) => void;
/** Se está em modo de edição */
editable?: boolean;
}
export function AvatarUpload({
userId,
currentAvatarUrl,
name = "",
color = "blue",
size = "xl",
onAvatarUpdate,
editable = true,
}: AvatarUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [displayUrl, setDisplayUrl] = useState<string | undefined>(
currentAvatarUrl
);
const fileInputRef = useRef<HTMLInputElement>(null);
// Atualiza displayUrl quando currentAvatarUrl muda externamente
useEffect(() => {
setDisplayUrl(currentAvatarUrl);
}, [currentAvatarUrl]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !userId) return;
// Validação de tamanho (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error("Arquivo muito grande! Tamanho máximo: 2MB");
return;
}
// Validação de tipo
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error("Formato inválido! Use JPG, PNG ou WebP");
return;
}
setIsUploading(true);
setShowMenu(false);
try {
// Upload do avatar
await avatarService.upload({
userId,
file,
});
// Gera URL pública com cache-busting
const ext = file.name.split(".").pop()?.toLowerCase();
const avatarExt =
ext === "jpg" || ext === "png" || ext === "webp" ? ext : "jpg";
const baseUrl = avatarService.getPublicUrl({
userId,
ext: avatarExt,
});
// Adiciona timestamp para forçar reload da imagem
const publicUrl = `${baseUrl}?t=${Date.now()}`;
// Atualiza no perfil (salva sem o timestamp)
await profileService.updateAvatar(userId, { avatar_url: baseUrl });
// Atualiza estado local com timestamp
setDisplayUrl(publicUrl);
// Callback com timestamp para forçar reload imediato no componente
onAvatarUpdate?.(publicUrl);
toast.success("Avatar atualizado com sucesso!");
} catch (error) {
console.error("Erro ao fazer upload:", error);
toast.error("Erro ao fazer upload do avatar");
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemove = async () => {
if (!userId) return;
if (!confirm("Tem certeza que deseja remover o avatar?")) {
setShowMenu(false);
return;
}
setIsUploading(true);
setShowMenu(false);
try {
await avatarService.delete({ userId });
await profileService.updateAvatar(userId, { avatar_url: null });
// Atualiza estado local
setDisplayUrl(undefined);
onAvatarUpdate?.(null);
toast.success("Avatar removido com sucesso!");
} catch (error) {
console.error("Erro ao remover avatar:", error);
toast.error("Erro ao remover avatar");
} finally {
setIsUploading(false);
}
};
return (
<div className="relative inline-block">
{/* Avatar */}
<div className="relative">
<Avatar src={displayUrl} name={name} size={size} color={color} border />
{/* Loading overlay */}
{isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Edit button */}
{editable && !isUploading && (
<button
type="button"
onClick={() => setShowMenu(!showMenu)}
className="absolute bottom-0 right-0 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100 transition-colors border-2 border-white"
title="Editar avatar"
>
<Camera className="w-4 h-4 text-gray-700" />
</button>
)}
</div>
{/* Menu dropdown */}
{showMenu && editable && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
{/* Menu */}
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50 min-w-[200px]">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{currentAvatarUrl ? "Trocar foto" : "Adicionar foto"}
</button>
{currentAvatarUrl && (
<button
type="button"
onClick={handleRemove}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Remover foto
</button>
)}
<button
type="button"
onClick={() => setShowMenu(false)}
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-100 flex items-center gap-2 border-t border-gray-200 mt-1 pt-2"
>
<X className="w-4 h-4" />
Cancelar
</button>
</div>
</>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}

View File

@ -6,10 +6,34 @@ import React, {
useState,
} from "react";
import toast from "react-hot-toast";
import medicoService, { type Medico } from "../services/medicoService";
import authService, {
type UserInfoFullResponse,
} from "../services/authService"; // tokens + user-info
import { authService, userService } from "../services";
// Tipos auxiliares
interface UserInfoFullResponse {
access_token: string;
refresh_token: string;
user: {
id: string;
email?: string;
user_metadata?: any;
};
roles?: string[];
permissions?: any;
profile?: {
full_name?: string;
};
}
// Mock temporário para compatibilidade
const doctorService = {
loginMedico: async (email: string, senha: string) => ({
success: false,
error: "Use login unificado",
data: null as any,
}),
};
type Medico = any;
// tokenManager removido no modelo somente Supabase (sem usuário técnico)
// Tipos de roles suportados
@ -115,13 +139,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
parsed.user.nome
);
setUser(parsed.user);
// Restaurar tokens também
if (parsed.token) {
import("../services/tokenStore").then((module) => {
module.default.setTokens(parsed.token!, parsed.refreshToken);
});
}
// Token restoration is handled automatically by authService
}
} catch (e) {
console.error("[AuthContext] Erro ao recuperar sessão:", e);
@ -202,28 +220,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
hasToken: !!parsed.token,
});
// Verificar se há tokens válidos salvos
// Token management is handled automatically by authService
if (parsed.token) {
console.log("[AuthContext] Restaurando tokens no tokenStore");
const tokenStore = (await import("../services/tokenStore"))
.default;
tokenStore.setTokens(parsed.token, parsed.refreshToken);
console.log("[AuthContext] Sessão com token encontrada");
} else {
console.warn(
"[AuthContext] Sessão encontrada mas sem token. Verificando tokenStore..."
"[AuthContext] ⚠️ Sessão encontrada mas sem token. Pode estar inválida."
);
const tokenStore = (await import("../services/tokenStore"))
.default;
const existingToken = tokenStore.getAccessToken();
if (existingToken) {
console.log(
"[AuthContext] Token encontrado no tokenStore, mantendo sessão"
);
} else {
console.warn(
"[AuthContext] ⚠️ Nenhum token encontrado. Sessão pode estar inválida."
);
}
}
console.log(
"[AuthContext] 📝 Chamando setUser com:",
@ -242,6 +245,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
}
} catch (error) {
console.error("[AuthContext] ❌ Erro ao restaurar sessão:", error);
// Se houver erro ao restaurar, limpar tudo para evitar loops
console.log("[AuthContext] 🧹 Limpando localStorage devido a erro");
localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY);
} finally {
console.log(
"[AuthContext] 🏁 Finalizando restauração, setLoading(false)"
@ -319,21 +326,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const buildSessionUser = React.useCallback(
(info: UserInfoFullResponse): SessionUser => {
console.log(
"[buildSessionUser] info recebido:",
JSON.stringify(info, null, 2)
);
// ⚠️ SEGURANÇA: Nunca logar tokens ou dados sensíveis em produção
const rolesNormalized = (info.roles || [])
.map(normalizeRole)
.filter(Boolean) as UserRole[];
console.log("[buildSessionUser] roles normalizadas:", rolesNormalized);
const permissions = info.permissions || {};
const primaryRole = pickPrimaryRole(
rolesNormalized.length
? rolesNormalized
: [normalizeRole((info.roles || [])[0]) || "paciente"]
);
console.log("[buildSessionUser] primaryRole escolhida:", primaryRole);
const base = {
id: info.user?.id || "",
nome:
@ -345,7 +347,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
roles: rolesNormalized,
permissions,
} as SessionUserBase;
console.log("[buildSessionUser] SessionUser final:", base);
if (primaryRole === "medico") {
return { ...base, role: "medico" } as MedicoUser;
}
@ -390,7 +391,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
// LEGADO: usa service de médicos sem validar senha real (apenas existência)
const loginMedico = useCallback(
async (email: string, senha: string) => {
const resp = await medicoService.loginMedico(email, senha);
const resp = await doctorService.loginMedico(email, senha);
if (!resp.success || !resp.data) {
toast.error(resp.error || "Erro ao autenticar médico");
return false;
@ -414,45 +415,41 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
[persist]
);
// Fluxo unificado real usando authService + endpoint user-info para mapear role dinâmica
// Fluxo unificado real usando authService
const loginComEmailSenha = useCallback(
async (email: string, senha: string) => {
console.log("[AuthContext] Iniciando login para:", email);
const loginResp = await authService.login({ email, password: senha });
console.log("[AuthContext] Resposta login:", loginResp);
try {
const loginResp = await authService.login({ email, password: senha });
if (!loginResp.success || !loginResp.data) {
console.error("[AuthContext] Login falhou:", loginResp.error);
toast.error(loginResp.error || "Falha no login");
// Fetch full user info with roles and permissions
const userInfo = await userService.getUserInfo();
// Build session user from full user info
const sessionUser = buildSessionUser({
access_token: loginResp.access_token,
refresh_token: loginResp.refresh_token,
user: userInfo.user,
roles: userInfo.roles,
permissions: userInfo.permissions,
profile: userInfo.profile
? { full_name: userInfo.profile.full_name }
: undefined,
} as UserInfoFullResponse);
setUser(sessionUser);
persist({
user: sessionUser,
savedAt: new Date().toISOString(),
token: loginResp.access_token,
refreshToken: loginResp.refresh_token,
});
toast.success("Login realizado");
return true;
} catch (error) {
console.error("[AuthContext] Login falhou:", error);
toast.error("Falha no login");
return false;
}
console.log("[AuthContext] Token recebido, buscando user-info...");
// Buscar user-info para descobrir papel
const infoResp = await authService.getUserInfo();
console.log("[AuthContext] Resposta user-info:", infoResp);
if (!infoResp.success || !infoResp.data) {
console.error(
"[AuthContext] Falha ao obter user-info:",
infoResp.error
);
toast.error(infoResp.error || "Falha ao obter user-info");
return false;
}
const sessionUser = buildSessionUser(infoResp.data);
console.log("[AuthContext] Usuário da sessão criado:", sessionUser);
setUser(sessionUser);
persist({
user: sessionUser,
savedAt: new Date().toISOString(),
token: loginResp.data.access_token,
refreshToken: loginResp.data.refresh_token,
});
console.log("[AuthContext] Login completo!");
toast.success("Login realizado");
return true;
},
[persist, buildSessionUser]
);
@ -496,16 +493,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const logout = useCallback(async () => {
console.log("[AuthContext] Iniciando logout...");
try {
const resp = await authService.logout(); // chama /auth/v1/logout (204 esperado)
if (!resp.success && resp.error) {
console.warn("[AuthContext] Falha no logout remoto:", resp.error);
toast.error(`Falha no logout remoto: ${resp.error}`);
} else {
console.log("[AuthContext] Logout remoto bem-sucedido");
}
await authService.logout(); // Returns void on success
console.log("[AuthContext] Logout remoto bem-sucedido");
} catch (e) {
console.warn(
"[AuthContext] Erro inesperado ao executar logout remoto",
"[AuthContext] Erro ao executar logout remoto (continuando limpeza local)",
e
);
} finally {
@ -513,14 +505,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
console.log("[AuthContext] Limpando estado local...");
setUser(null);
clearPersisted();
authService.clearLocalAuth();
try {
localStorage.removeItem("pacienteLogado");
} catch {
// ignore
}
console.log("[AuthContext] Logout completo - usuário removido do estado");
// Modelo somente Supabase: nenhum token técnico para invalidar
}
}, [clearPersisted]);

View File

@ -1,68 +0,0 @@
[
{
"id": "consulta-demo-guilherme-001",
"pacienteId": "864b1785-461f-4e92-8b74-2a6f17c58a80",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Guilherme Silva Gomes - SQUAD 18",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-10-05T10:00:00",
"status": "agendada",
"tipo": "Consulta",
"observacoes": "Primeira consulta - Check-up geral"
},
{
"id": "consulta-demo-guilherme-002",
"pacienteId": "864b1785-461f-4e92-8b74-2a6f17c58a80",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Guilherme Silva Gomes - SQUAD 18",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-09-28T14:30:00",
"status": "realizada",
"tipo": "Retorno",
"observacoes": "Consulta de retorno - Avaliação de exames"
},
{
"id": "consulta-demo-guilherme-003",
"pacienteId": "864b1785-461f-4e92-8b74-2a6f17c58a80",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Guilherme Silva Gomes - SQUAD 18",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-10-10T09:00:00",
"status": "confirmada",
"tipo": "Consulta",
"observacoes": "Consulta de acompanhamento mensal"
},
{
"id": "consulta-demo-pedro-001",
"pacienteId": "pedro.araujo@mediconnect.com",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Pedro Araujo",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-10-07T10:00:00",
"status": "agendada",
"tipo": "Consulta",
"observacoes": "Primeira avaliação clínica do Pedro."
},
{
"id": "consulta-demo-pedro-002",
"pacienteId": "pedro.araujo@mediconnect.com",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Pedro Araujo",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-10-12T09:00:00",
"status": "confirmada",
"tipo": "Retorno",
"observacoes": "Retorno para revisar sintomas."
},
{
"id": "consulta-demo-pedro-003",
"pacienteId": "pedro.araujo@mediconnect.com",
"medicoId": "be1e3cba-534e-48c3-9590-b7e55861cade",
"pacienteNome": "Pedro Araujo",
"medicoNome": "Fernando Pirichowski - Squad 18",
"dataHora": "2025-10-19T11:00:00",
"status": "agendada",
"tipo": "Exame",
"observacoes": "Agendamento de exame complementar."
}
]

View File

@ -1,85 +0,0 @@
{
"name": "consultas",
"fields": [
{
"name": "pacienteId",
"type": "string",
"required": true,
"title": "ID do Paciente"
},
{
"name": "medicoId",
"type": "string",
"required": true,
"title": "ID do Médico"
},
{
"name": "dataHora",
"type": "string",
"required": true,
"title": "datetime"
},
{
"name": "status",
"type": "string",
"required": true,
"enum": ["agendada", "confirmada", "realizada", "cancelada", "faltou"],
"title": "Status da Consulta"
},
{
"name": "tipoConsulta",
"type": "string",
"required": true,
"enum": ["primeira-vez", "retorno", "urgencia"],
"title": "Tipo de Consulta"
},
{
"name": "motivoConsulta",
"type": "string",
"title": "Motivo da Consulta"
},
{
"name": "observacoes",
"type": "string",
"title": "Observações"
},
{
"name": "resultados",
"type": "string",
"title": "Resultados da Consulta"
},
{
"name": "prescricoes",
"type": "string",
"title": "Prescrições Médicas"
},
{
"name": "proximaConsulta",
"type": "string",
"title": "Próxima Consulta Recomendada"
},
{
"name": "lembrete",
"type": "boolean",
"default": false,
"title": "Lembrete Enviado"
},
{
"name": "criadoPor",
"type": "string",
"enum": ["paciente", "secretaria", "medico"],
"title": "Criado Por"
},
{
"name": "criadoEm",
"type": "string",
"title": "datetime"
},
{
"name": "atualizadoEm",
"type": "string",
"title": "datetime"
}
]
}

View File

@ -1,74 +0,0 @@
{
"name": "medicos",
"fields": [
{
"name": "nome",
"type": "string",
"required": true,
"title": "Nome Completo"
},
{
"name": "email",
"type": "string",
"required": true,
"title": "Email"
},
{
"name": "senha",
"type": "string",
"required": true,
"title": "Senha"
},
{
"name": "crm",
"type": "string",
"required": true,
"title": "CRM"
},
{
"name": "especialidade",
"type": "string",
"required": true,
"title": "Especialidade"
},
{
"name": "telefone",
"type": "string",
"required": true,
"title": "Telefone"
},
{
"name": "valorConsulta",
"type": "number",
"required": true,
"title": "Valor da Consulta"
},
{
"name": "horarioAtendimento",
"type": "object",
"title": "Horários de Atendimento"
},
{
"name": "observacoes",
"type": "string",
"title": "Observações"
},
{
"name": "ativo",
"type": "boolean",
"title": "Ativo",
"default": true
},
{
"name": "criadoEm",
"type": "string",
"title": "datetime"
},
{
"name": "atualizadoEm",
"type": "string",
"title": "datetime"
}
]
}

View File

@ -1,77 +0,0 @@
{
"name": "pacientes",
"fields": [
{
"name": "nome",
"type": "string",
"required": true,
"title": "Nome Completo"
},
{
"name": "email",
"type": "string",
"required": true,
"title": "Email"
},
{
"name": "senha",
"type": "string",
"required": true,
"title": "Senha"
},
{
"name": "telefone",
"type": "string",
"required": true,
"title": "Telefone"
},
{
"name": "cpf",
"type": "string",
"required": true,
"title": "CPF"
},
{
"name": "dataNascimento",
"type": "string",
"title": "datetime",
"required": true
},
{
"name": "convenio",
"type": "string",
"title": "Convênio"
},
{
"name": "altura",
"type": "number",
"title": "Altura (cm)"
},
{
"name": "peso",
"type": "number",
"title": "Peso (kg)"
},
{
"name": "observacoes",
"type": "string",
"title": "Observações"
},
{
"name": "ativo",
"type": "boolean",
"title": "Ativo",
"default": true
},
{
"name": "criadoEm",
"type": "string",
"title": "datetime"
},
{
"name": "atualizadoEm",
"type": "string",
"title": "datetime"
}
]
}

View File

@ -1,3 +1,5 @@
@import "./styles/design-system.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -5,15 +7,21 @@
@layer base {
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background-color: #f8fafc;
}
}
/* Dark mode hard fallback (ensure full-page background) */
html.dark, html.dark body, html.dark #root, html.dark .app-root {
background-color: #0f172a !important;
background-image: linear-gradient(to bottom right, #0f172a, #1e293b) !important;
}
/* Dark mode hard fallback (ensure full-page background) */
html.dark,
html.dark body,
html.dark #root,
html.dark .app-root {
background-color: #0f172a !important;
background-image: linear-gradient(
to bottom right,
#0f172a,
#1e293b
) !important;
}
/* Fontes alternativas acessibilidade */
@font-face {
@ -32,7 +40,8 @@
/* Quando a fonte OpenDyslexic não estiver disponível, use um fallback amigável à dislexia (Comic Sans)
e aplique ajustes de legibilidade para garantir diferença visual imediata */
html.dyslexic-font body {
font-family: "OpenDyslexic", "Comic Sans MS", "Comic Sans", "Inter", system-ui, -apple-system, sans-serif;
font-family: "OpenDyslexic", "Comic Sans MS", "Comic Sans", "Inter", system-ui,
-apple-system, sans-serif;
letter-spacing: 0.02em;
word-spacing: 0.04em;
font-variant-ligatures: none;
@ -189,27 +198,28 @@ html.focus-mode.dark *:focus-visible,
outline: none;
}
.a11y-toggle-button:focus-visible {
box-shadow: 0 0 0 3px rgba(59,130,246,0.7), 0 0 0 5px rgba(255,255,255,0.9);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.7),
0 0 0 5px rgba(255, 255, 255, 0.9);
}
.a11y-toggle-track {
transition: background-color 0.25s ease, box-shadow 0.25s ease;
box-shadow: inset 0 0 0 2px rgba(0,0,0,0.15);
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.15);
}
.dark .a11y-toggle-track {
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.12);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.a11y-toggle-thumb {
transition: transform 0.25s ease, background-color 0.25s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.a11y-toggle-track[data-active="true"] {
background: linear-gradient(90deg,#2563eb,#3b82f6) !important;
background: linear-gradient(90deg, #2563eb, #3b82f6) !important;
}
.a11y-toggle-track[data-active="false"] {
background: linear-gradient(90deg,#cbd5e1,#94a3b8) !important;
background: linear-gradient(90deg, #cbd5e1, #94a3b8) !important;
}
.dark .a11y-toggle-track[data-active="false"] {
background: linear-gradient(90deg,#475569,#334155) !important;
background: linear-gradient(90deg, #475569, #334155) !important;
}
.a11y-toggle-track[data-active="true"] .a11y-toggle-thumb {
background: #fff;
@ -217,15 +227,21 @@ html.focus-mode.dark *:focus-visible,
.a11y-toggle-status-label {
font-size: 0.625rem; /* 10px */
font-weight: 600;
letter-spacing: .5px;
letter-spacing: 0.5px;
text-transform: uppercase;
display: inline-block;
margin-top: 2px;
color: #64748b;
}
.dark .a11y-toggle-status-label { color: #94a3b8; }
.a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label { color: #2563eb; }
.dark .a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label { color: #60a5fa; }
.dark .a11y-toggle-status-label {
color: #94a3b8;
}
.a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label {
color: #2563eb;
}
.dark .a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label {
color: #60a5fa;
}
/* Containers e Cards */
.dark .bg-white {

View File

@ -1,92 +0,0 @@
/**
* Utilidade para carregar consultas de demonstração
* Importar em qualquer componente que precise das consultas
*/
import consultasDemo from "../data/consultas-demo.json";
export interface ConsultaDemo {
id: string;
pacienteId: string;
medicoId: string;
pacienteNome: string;
medicoNome: string;
dataHora: string;
status: string;
tipo: string;
observacoes: string;
}
/**
* Carrega as consultas de demonstração no localStorage
*/
export function carregarConsultasDemo(): void {
try {
const consultasExistentes = localStorage.getItem("consultas_local");
if (!consultasExistentes) {
console.log("📊 Carregando consultas de demonstração...");
localStorage.setItem("consultas_local", JSON.stringify(consultasDemo));
console.log(`${consultasDemo.length} consultas carregadas!`);
} else {
// Mesclar com consultas existentes
const existentes = JSON.parse(consultasExistentes);
const ids = new Set(existentes.map((c: ConsultaDemo) => c.id));
const novas = consultasDemo.filter((c) => !ids.has(c.id));
if (novas.length > 0) {
const mescladas = [...existentes, ...novas];
localStorage.setItem("consultas_local", JSON.stringify(mescladas));
console.log(`${novas.length} novas consultas adicionadas!`);
}
}
} catch (error) {
console.error("❌ Erro ao carregar consultas:", error);
}
}
/**
* Obtém as consultas de demonstração
*/
export function getConsultasDemo(): ConsultaDemo[] {
return consultasDemo;
}
/**
* Obtém consultas do paciente Guilherme
*/
export function getConsultasGuilherme(): ConsultaDemo[] {
return consultasDemo.filter((c) => c.pacienteNome.includes("Guilherme"));
}
/**
* Obtém consultas do médico Fernando
*/
export function getConsultasFernando(): ConsultaDemo[] {
return consultasDemo.filter((c) => c.medicoNome.includes("Fernando"));
}
/**
* Limpa todas as consultas do localStorage
*/
export function limparConsultas(): void {
localStorage.removeItem("consultas_local");
console.log("🗑️ Consultas removidas do localStorage");
}
/**
* Recarrega as consultas de demonstração (sobrescreve)
*/
export function recarregarConsultasDemo(): void {
localStorage.setItem("consultas_local", JSON.stringify(consultasDemo));
console.log(`${consultasDemo.length} consultas recarregadas!`);
}
// Auto-carregar ao importar (opcional - pode comentar se não quiser)
if (typeof window !== "undefined") {
// Carregar automaticamente apenas em desenvolvimento
if (import.meta.env.DEV) {
carregarConsultasDemo();
}
}

View File

@ -1,5 +1,4 @@
import { StrictMode } from "react";
import "./bootstrap/initServiceToken"; // inicializa token técnico (service account)
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";

View File

@ -13,17 +13,15 @@ import {
CheckCircle,
XCircle,
AlertCircle,
Plus,
Search,
Star,
FileText,
} from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import consultaService from "../services/consultaService";
import medicoService from "../services/medicoService";
import { appointmentService, doctorService, reportService } from "../services";
import type { Report } from "../services/reports/types";
import AgendamentoConsulta from "../components/AgendamentoConsulta";
interface Consulta {
@ -38,15 +36,20 @@ interface Consulta {
resultados?: string;
prescricoes?: string;
proximaConsulta?: string;
medicoNome?: string;
especialidade?: string;
valorConsulta?: number;
}
interface Medico {
_id?: string;
id?: string;
id: string;
nome: string;
especialidade: string;
crm: string;
foto?: string;
email?: string;
telefone?: string;
valorConsulta?: number;
valor_consulta?: number;
}
const AcompanhamentoPaciente: React.FC = () => {
@ -57,8 +60,12 @@ const AcompanhamentoPaciente: React.FC = () => {
const [activeTab, setActiveTab] = useState("dashboard");
const [consultas, setConsultas] = useState<Consulta[]>([]);
const [medicos, setMedicos] = useState<Medico[]>([]);
const [loadingMedicos, setLoadingMedicos] = useState(true);
const [selectedMedicoId, setSelectedMedicoId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [especialidadeFiltro, setEspecialidadeFiltro] = useState<string>("");
const [laudos, setLaudos] = useState<Report[]>([]);
const [loadingLaudos, setLoadingLaudos] = useState(false);
const pacienteId = user?.id || "";
const pacienteNome = user?.nome || "Paciente";
@ -72,43 +79,53 @@ const AcompanhamentoPaciente: React.FC = () => {
const fetchConsultas = useCallback(async () => {
if (!pacienteId) return;
setLoading(true);
setLoadingMedicos(true);
try {
// Buscar consultas da API
const consultasResp = await consultaService.listarConsultas({
paciente_id: pacienteId,
// Buscar agendamentos da API
const appointments = await appointmentService.list({
patient_id: pacienteId,
limit: 50,
order: "scheduled_at.desc",
});
// Buscar médicos
const medicosResp = await medicoService.listarMedicos({});
if (medicosResp.success && medicosResp.data) {
setMedicos(medicosResp.data.data as Medico[]);
}
const medicosData = await doctorService.list();
const medicosFormatted: Medico[] = medicosData.map((d) => ({
id: d.id,
nome: d.full_name,
especialidade: d.specialty || "",
crm: d.crm,
email: d.email,
telefone: d.phone_mobile || undefined,
}));
setMedicos(medicosFormatted);
setLoadingMedicos(false);
if (consultasResp.success && consultasResp.data) {
const consultasData = Array.isArray(consultasResp.data)
? consultasResp.data
: consultasResp.data.data || [];
// Map appointments to old Consulta format
const consultasAPI: Consulta[] = appointments.map((apt) => ({
_id: apt.id,
pacienteId: apt.patient_id,
medicoId: apt.doctor_id,
dataHora: apt.scheduled_at || "",
status:
apt.status === "confirmed"
? "confirmada"
: apt.status === "completed"
? "realizada"
: apt.status === "cancelled"
? "cancelada"
: apt.status === "no_show"
? "faltou"
: "agendada",
tipoConsulta: "presencial",
motivoConsulta: apt.notes || "Consulta médica",
observacoes: apt.notes || undefined,
}));
setConsultas(
consultasData.map((c) => ({
_id: c._id || c.id,
pacienteId: c.pacienteId,
medicoId: c.medicoId,
dataHora: c.dataHora,
status: c.status || "agendada",
tipoConsulta: c.tipoConsulta || c.tipo || "presencial",
motivoConsulta:
c.motivoConsulta || c.observacoes || "Consulta médica",
observacoes: c.observacoes,
resultados: c.resultados,
prescricoes: c.prescricoes,
proximaConsulta: c.proximaConsulta,
}))
);
} else {
setConsultas([]);
}
// Set consultas
setConsultas(consultasAPI);
} catch (error) {
setLoadingMedicos(false);
console.error("Erro ao carregar consultas:", error);
toast.error("Erro ao carregar consultas");
setConsultas([]);
@ -121,6 +138,34 @@ const AcompanhamentoPaciente: React.FC = () => {
fetchConsultas();
}, [fetchConsultas]);
// Recarregar consultas quando mudar para a aba de consultas
const fetchLaudos = useCallback(async () => {
if (!pacienteId) return;
setLoadingLaudos(true);
try {
const data = await reportService.list({ patient_id: pacienteId });
setLaudos(data);
} catch (error) {
console.error("Erro ao buscar laudos:", error);
toast.error("Erro ao carregar laudos");
setLaudos([]);
} finally {
setLoadingLaudos(false);
}
}, [pacienteId]);
useEffect(() => {
if (activeTab === "appointments") {
fetchConsultas();
}
}, [activeTab, fetchConsultas]);
useEffect(() => {
if (activeTab === "reports") {
fetchLaudos();
}
}, [activeTab, fetchLaudos]);
const getMedicoNome = (medicoId: string) => {
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
return medico?.nome || "Médico";
@ -142,8 +187,8 @@ const AcompanhamentoPaciente: React.FC = () => {
}
try {
await consultaService.atualizarConsulta(consultaId, {
status: "cancelada",
await appointmentService.update(consultaId, {
status: "cancelled",
});
toast.success("Consulta cancelada com sucesso");
fetchConsultas();
@ -218,10 +263,17 @@ const AcompanhamentoPaciente: React.FC = () => {
const menuItems = [
{ id: "dashboard", label: "Início", icon: Home },
{ id: "appointments", label: "Minhas Consultas", icon: Calendar },
{ id: "reports", label: "Meus Laudos", icon: FileText },
{ id: "book", label: "Agendar Consulta", icon: Stethoscope },
{ id: "messages", label: "Mensagens", icon: MessageCircle },
{
id: "profile",
label: "Meu Perfil",
icon: User,
isLink: true,
path: "/perfil-paciente",
},
{ id: "help", label: "Ajuda", icon: HelpCircle },
{ id: "profile", label: "Meu Perfil", icon: User },
];
// Sidebar
@ -257,7 +309,9 @@ const AcompanhamentoPaciente: React.FC = () => {
<button
key={item.id}
onClick={() => {
if (item.id === "help") {
if (item.isLink && item.path) {
navigate(item.path);
} else if (item.id === "help") {
navigate("/ajuda");
} else {
setActiveTab(item.id);
@ -326,8 +380,10 @@ const AcompanhamentoPaciente: React.FC = () => {
consulta: Consulta,
isPast: boolean = false
) => {
const medicoNome = getMedicoNome(consulta.medicoId);
const especialidade = getMedicoEspecialidade(consulta.medicoId);
// Usar dados da consulta local se disponível, senão buscar pelo ID do médico
const medicoNome = consulta.medicoNome || getMedicoNome(consulta.medicoId);
const especialidade =
consulta.especialidade || getMedicoEspecialidade(consulta.medicoId);
return (
<div
@ -661,7 +717,11 @@ const AcompanhamentoPaciente: React.FC = () => {
);
// Book Appointment Content
const renderBookAppointment = () => <AgendamentoConsulta />;
const renderBookAppointment = () => (
<div className="space-y-6">
<AgendamentoConsulta medicos={medicos} />
</div>
);
// Messages Content
const renderMessages = () => (
@ -723,12 +783,104 @@ const AcompanhamentoPaciente: React.FC = () => {
</div>
);
const renderReports = () => (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Meus Laudos Médicos
</h1>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
{loadingLaudos ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Carregando laudos...
</p>
</div>
) : laudos.length === 0 ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Você ainda não possui laudos médicos.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-slate-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Número
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Exame
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Diagnóstico
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Data
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
{laudos.map((laudo) => (
<tr
key={laudo.id}
className="hover:bg-gray-50 dark:hover:bg-slate-800"
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{laudo.order_number}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.exam || "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.diagnosis || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
laudo.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: laudo.status === "pending"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: laudo.status === "cancelled"
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}
>
{laudo.status === "completed"
? "Concluído"
: laudo.status === "pending"
? "Pendente"
: laudo.status === "cancelled"
? "Cancelado"
: "Rascunho"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
const renderContent = () => {
switch (activeTab) {
case "dashboard":
return renderDashboard();
case "appointments":
return renderAppointments();
case "reports":
return renderReports();
case "book":
return renderBookAppointment();
case "messages":

View File

@ -1,806 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Calendar,
Clock,
User,
CheckCircle,
XCircle,
AlertCircle,
LogOut,
Eye,
Filter,
} from "lucide-react";
import consultaService from "../services/consultaService";
import medicoService from "../services/medicoService";
import toast from "react-hot-toast";
import { format, isAfter, isBefore, isToday, addDays } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useNavigate } from "react-router-dom";
interface Consulta {
_id: string;
pacienteId: string;
medicoId: string;
dataHora: string;
status: "agendada" | "confirmada" | "realizada" | "cancelada" | "faltou";
tipoConsulta: string;
motivoConsulta: string;
observacoes?: string;
resultados?: string;
prescricoes?: string;
proximaConsulta?: string;
criadoEm: string;
}
interface Medico {
_id: string;
nome: string;
especialidade: string;
valorConsulta: number;
}
interface Paciente {
_id: string;
nome: string;
cpf: string;
telefone: string;
email: string;
}
const AcompanhamentoPaciente: React.FC = () => {
const [consultas, setConsultas] = useState<Consulta[]>([]);
const [medicos, setMedicos] = useState<Medico[]>([]);
const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null);
const [loading, setLoading] = useState(true);
const [filtroStatus, setFiltroStatus] = useState<string>("todas");
const [filtroPeriodo, setFiltroPeriodo] = useState<string>("todos");
const [consultaSelecionada, setConsultaSelecionada] =
useState<Consulta | null>(null);
const [showDetalhes, setShowDetalhes] = useState(false);
const navigate = useNavigate();
// (Effect moved below callback declarations)
// Mesclar consultas locais do localStorage com as do backend (apenas visual)
interface LocalConsultaRaw {
id: string;
pacienteId: string;
medicoId: string;
pacienteNome?: string;
medicoNome?: string;
dataHora: string;
tipo?: string;
status: string;
}
const mergeConsultasLocais = useCallback(
(pacienteId: string, pacienteEmail?: string) => {
try {
const raw = localStorage.getItem("consultas_local");
if (!raw) return;
const arr: LocalConsultaRaw[] = JSON.parse(raw);
console.log("[mergeConsultasLocais] Filtrando consultas. Procurando:", {
pacienteId,
pacienteEmail,
});
console.log(
"[mergeConsultasLocais] Total no localStorage:",
arr.length
);
const minhas = arr.filter((c) => {
const match =
c.pacienteId === pacienteId ||
(pacienteEmail && c.pacienteId === pacienteEmail) ||
c.pacienteId === pacienteEmail;
if (match) {
console.log(
"[mergeConsultasLocais] Match encontrado:",
c.id,
c.pacienteId
);
}
return match;
});
console.log(
"[mergeConsultasLocais] Consultas filtradas:",
minhas.length
);
if (!minhas.length) {
console.log(
"[mergeConsultasLocais] Nenhuma consulta encontrada para este paciente"
);
return;
}
setConsultas((prev) => {
const existentes = new Set(prev.map((c) => c._id));
const extras: Consulta[] = minhas
.filter((c) => !existentes.has(c.id))
.map((c) => ({
_id: c.id,
pacienteId: c.pacienteId,
medicoId: c.medicoId,
dataHora: c.dataHora,
status: (c.status as Consulta["status"]) || "agendada",
tipoConsulta: c.tipo || "",
motivoConsulta: "",
criadoEm: c.dataHora,
}));
console.log(
"[mergeConsultasLocais] Adicionando",
extras.length,
"consultas ao estado"
);
return [...prev, ...extras];
});
} catch {
/* ignore */
}
},
[]
);
// Carrega e injeta consultas de demonstração automaticamente se ainda não presentes
const ensureDemoConsultas = useCallback(
async (pacienteId: string, pacienteEmail?: string) => {
try {
const rawLocal = localStorage.getItem("consultas_local");
const existentes: LocalConsultaRaw[] = rawLocal
? JSON.parse(rawLocal)
: [];
const jaTem = existentes.some(
(c) =>
c.pacienteId === pacienteId ||
(pacienteEmail && c.pacienteId === pacienteEmail)
);
if (!jaTem) {
const resp = await fetch("/src/data/consultas-demo.json").catch(() =>
Promise.resolve(undefined)
);
if (resp && resp.ok) {
const demo: LocalConsultaRaw[] = await resp.json();
const candidatos = demo.filter(
(c) =>
c.pacienteId === pacienteId ||
(pacienteEmail && c.pacienteId === pacienteEmail)
);
if (candidatos.length) {
const idsExist = new Set(existentes.map((c) => c.id));
const novos = candidatos.filter((c) => !idsExist.has(c.id));
if (novos.length) {
localStorage.setItem(
"consultas_local",
JSON.stringify([...existentes, ...novos])
);
}
}
}
}
mergeConsultasLocais(pacienteId, pacienteEmail);
} catch (e) {
console.error("Erro ao carregar consultas de demonstração:", e);
}
},
[mergeConsultasLocais]
); // Efetua carregamento inicial após definição das callbacks
useEffect(() => {
const pacienteData = localStorage.getItem("pacienteLogado");
if (!pacienteData) {
navigate("/paciente");
return;
}
try {
const paciente = JSON.parse(pacienteData);
setPacienteLogado(paciente);
fetchConsultas(paciente._id);
ensureDemoConsultas(paciente._id, paciente.email);
fetchMedicos();
} catch (error) {
console.error("Erro ao carregar dados do paciente:", error);
navigate("/paciente");
}
}, [navigate, ensureDemoConsultas]);
const fetchConsultas = async (pacienteId: string) => {
try {
const response = await consultaService.listarConsultas({
paciente_id: pacienteId,
});
const list = response.data?.data || [];
const mapped: Consulta[] = list.map((c) => ({
_id: c.id || Math.random().toString(36).slice(2, 9),
pacienteId: c.paciente_id || "",
medicoId: c.medico_id || "",
dataHora: c.data_hora || new Date().toISOString(),
status: c.status || "agendada",
tipoConsulta: c.tipo_consulta || "",
motivoConsulta: c.motivo_consulta || "",
observacoes: c.observacoes,
resultados: "",
prescricoes: "",
proximaConsulta: "",
criadoEm: c.created_at || new Date().toISOString(),
}));
setConsultas(mapped);
} catch (error) {
console.error("Erro ao carregar consultas:", error);
toast.error("Erro ao carregar suas consultas");
} finally {
setLoading(false);
}
};
const fetchMedicos = async () => {
try {
const response = await medicoService.listarMedicos();
const list = response.data?.data || [];
const mapped: Medico[] = list.map((m) => ({
_id: m.id || Math.random().toString(36).slice(2, 9),
nome: m.nome || "",
especialidade: m.especialidade || "",
valorConsulta: 0,
}));
setMedicos(mapped);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
}
};
const getMedicoNome = (medicoId: string) => {
const medico = medicos.find((m) => m._id === medicoId);
return medico ? medico.nome : "Médico não encontrado";
};
const getMedicoEspecialidade = (medicoId: string) => {
const medico = medicos.find((m) => m._id === medicoId);
return medico ? medico.especialidade : "";
};
const getStatusColor = (status: string) => {
switch (status) {
case "agendada":
return "bg-blue-100 text-blue-800";
case "confirmada":
return "bg-green-100 text-green-800";
case "realizada":
return "bg-gray-100 text-gray-800";
case "cancelada":
return "bg-red-100 text-red-800";
case "faltou":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "agendada":
return <Clock className="w-4 h-4" />;
case "confirmada":
return <CheckCircle className="w-4 h-4" />;
case "realizada":
return <CheckCircle className="w-4 h-4" />;
case "cancelada":
return <XCircle className="w-4 h-4" />;
case "faltou":
return <AlertCircle className="w-4 h-4" />;
default:
return <Clock className="w-4 h-4" />;
}
};
const getStatusTexto = (status: string) => {
switch (status) {
case "agendada":
return "Agendada";
case "confirmada":
return "Confirmada";
case "realizada":
return "Realizada";
case "cancelada":
return "Cancelada";
case "faltou":
return "Faltou";
default:
return status;
}
};
const filtrarConsultas = () => {
let consultasFiltradas = [...consultas];
// Filtro por status
if (filtroStatus !== "todas") {
consultasFiltradas = consultasFiltradas.filter(
(c) => c.status === filtroStatus
);
}
// Filtro por período
const hoje = new Date();
switch (filtroPeriodo) {
case "proximas":
consultasFiltradas = consultasFiltradas.filter(
(c) =>
isAfter(new Date(c.dataHora), hoje) &&
(c.status === "agendada" || c.status === "confirmada")
);
break;
case "hoje":
consultasFiltradas = consultasFiltradas.filter((c) =>
isToday(new Date(c.dataHora))
);
break;
case "semana":
{
const proximaSemana = addDays(hoje, 7);
consultasFiltradas = consultasFiltradas.filter(
(c) =>
isAfter(new Date(c.dataHora), hoje) &&
isBefore(new Date(c.dataHora), proximaSemana)
);
}
break;
case "historico":
consultasFiltradas = consultasFiltradas.filter((c) =>
isBefore(new Date(c.dataHora), hoje)
);
break;
}
return consultasFiltradas;
};
const abrirDetalhes = (consulta: Consulta) => {
setConsultaSelecionada(consulta);
setShowDetalhes(true);
};
const fecharDetalhes = () => {
setConsultaSelecionada(null);
setShowDetalhes(false);
};
const novoAgendamento = () => {
navigate("/agendamento");
};
const logout = () => {
localStorage.removeItem("pacienteLogado");
navigate("/paciente");
};
if (!pacienteLogado) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const consultasFiltradas = filtrarConsultas();
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header com Gradiente Aprimorado */}
<div className="bg-gradient-to-r from-blue-700 via-blue-600 to-blue-500 dark:from-blue-800 dark:via-blue-700 dark:to-blue-600 rounded-xl shadow-lg p-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="text-white">
<h1 className="text-4xl font-bold mb-2">
Olá, {pacienteLogado.nome}!
</h1>
<p className="text-blue-100 text-lg">
Gerencie suas consultas e acompanhe seu histórico médico
</p>
<div className="flex items-center gap-4 mt-3 text-sm text-blue-200">
<div className="flex items-center gap-1">
<User className="w-4 h-4" />
<span>Paciente</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
<span>{consultas.length} consultas registradas</span>
</div>
</div>
</div>
<div className="flex gap-3 w-full md:w-auto">
<button
onClick={novoAgendamento}
className="flex-1 md:flex-none flex items-center justify-center gap-2 bg-white hover:bg-blue-50 text-blue-700 px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-md hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2"
>
<Calendar className="w-5 h-5" />
<span>Nova Consulta</span>
</button>
<button
onClick={logout}
className="flex items-center justify-center gap-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-md hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2"
>
<LogOut className="w-5 h-5" />
<span>Sair</span>
</button>
</div>
</div>
</div>
{/* Cards de Estatísticas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Total
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{consultas.length}
</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
<Calendar className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Agendadas
</p>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">
{consultas.filter((c) => c.status === "agendada").length}
</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
<Clock className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Realizadas
</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
{consultas.filter((c) => c.status === "realizada").length}
</p>
</div>
<div className="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Canceladas
</p>
<p className="text-3xl font-bold text-red-600 dark:text-red-400 mt-2">
{consultas.filter((c) => c.status === "cancelada").length}
</p>
</div>
<div className="bg-red-100 dark:bg-red-900/30 p-3 rounded-lg">
<XCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
</div>
</div>
</div>
{/* Filtros */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-6">
<div className="bg-blue-100 dark:bg-blue-900/30 p-2 rounded-lg">
<Filter className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Filtrar Consultas
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status da Consulta
</label>
<select
value={filtroStatus}
onChange={(e) => setFiltroStatus(e.target.value)}
className="form-input"
>
<option value="todas">Todas</option>
<option value="agendada">Agendadas</option>
<option value="confirmada">Confirmadas</option>
<option value="realizada">Realizadas</option>
<option value="cancelada">Canceladas</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Período
</label>
<select
value={filtroPeriodo}
onChange={(e) => setFiltroPeriodo(e.target.value)}
className="form-input"
>
<option value="todos">Todos</option>
<option value="proximas">Próximas</option>
<option value="hoje">Hoje</option>
<option value="semana">Próximos 7 dias</option>
<option value="historico">Histórico</option>
</select>
</div>
</div>
</div>
{/* Lista de Consultas */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Suas Consultas
</h2>
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-sm font-medium px-3 py-1 rounded-full">
{consultasFiltradas.length}{" "}
{consultasFiltradas.length === 1 ? "consulta" : "consultas"}
</span>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : consultasFiltradas.length === 0 ? (
<div className="text-center p-12">
<div className="bg-gray-100 dark:bg-gray-700/30 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Nenhuma consulta encontrada
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
{filtroStatus !== "todas" || filtroPeriodo !== "todos"
? "Tente ajustar os filtros para ver mais consultas."
: "Você ainda não tem consultas agendadas."}
</p>
<button
onClick={novoAgendamento}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Agendar Primeira Consulta
</button>
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{consultasFiltradas.map((consulta) => (
<div
key={consulta._id}
className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-4 mb-2">
<span
className={`inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
consulta.status
)}`}
>
{getStatusIcon(consulta.status)}
<span>{getStatusTexto(consulta.status)}</span>
</span>
<span className="text-sm text-gray-500">
{consulta.tipoConsulta}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-gray-400" />
<div>
<p className="font-medium text-gray-900">
{getMedicoNome(consulta.medicoId)}
</p>
<p className="text-sm text-gray-500">
{getMedicoEspecialidade(consulta.medicoId)}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4 text-gray-400" />
<div>
<p className="font-medium text-gray-900">
{format(new Date(consulta.dataHora), "dd/MM/yyyy", {
locale: ptBR,
})}
</p>
<p className="text-sm text-gray-500">
{format(new Date(consulta.dataHora), "EEEE", {
locale: ptBR,
})}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 text-gray-400" />
<div>
<p className="font-medium text-gray-900">
{format(new Date(consulta.dataHora), "HH:mm")}
</p>
<p className="text-sm text-gray-500">
{consulta.motivoConsulta || "Consulta de rotina"}
</p>
</div>
</div>
</div>
</div>
<button
onClick={() => abrirDetalhes(consulta)}
className="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
aria-label="Ver detalhes da consulta"
>
<Eye className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal de Detalhes */}
{showDetalhes && consultaSelecionada && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="detalhes-consulta-title"
>
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex justify-between items-center">
<h3
id="detalhes-consulta-title"
className="text-lg font-semibold"
>
Detalhes da Consulta
</h3>
<button
onClick={fecharDetalhes}
aria-label="Fechar detalhes da consulta"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300 focus-visible:ring-offset-2"
>
<XCircle className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Informações Básicas */}
<div>
<h4 className="font-semibold mb-3">Informações da Consulta</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Médico:</span>
<p className="font-medium">
{getMedicoNome(consultaSelecionada.medicoId)}
</p>
</div>
<div>
<span className="text-gray-500">Especialidade:</span>
<p className="font-medium">
{getMedicoEspecialidade(consultaSelecionada.medicoId)}
</p>
</div>
<div>
<span className="text-gray-500">Data:</span>
<p className="font-medium">
{format(
new Date(consultaSelecionada.dataHora),
"dd/MM/yyyy - HH:mm",
{ locale: ptBR }
)}
</p>
</div>
<div>
<span className="text-gray-500">Status:</span>
<span
className={`inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
consultaSelecionada.status
)}`}
>
{getStatusIcon(consultaSelecionada.status)}
<span>{getStatusTexto(consultaSelecionada.status)}</span>
</span>
</div>
<div>
<span className="text-gray-500">Tipo:</span>
<p className="font-medium">
{consultaSelecionada.tipoConsulta}
</p>
</div>
</div>
</div>
{/* Motivo da Consulta */}
{consultaSelecionada.motivoConsulta && (
<div>
<h4 className="font-semibold mb-2">Motivo da Consulta</h4>
<p className="text-gray-700 bg-gray-50 p-3 rounded-lg">
{consultaSelecionada.motivoConsulta}
</p>
</div>
)}
{/* Observações */}
{consultaSelecionada.observacoes && (
<div>
<h4 className="font-semibold mb-2">Observações</h4>
<p className="text-gray-700 bg-gray-50 p-3 rounded-lg">
{consultaSelecionada.observacoes}
</p>
</div>
)}
{/* Resultados (só aparece se a consulta foi realizada) */}
{consultaSelecionada.status === "realizada" &&
consultaSelecionada.resultados && (
<div>
<h4 className="font-semibold mb-2">
Resultados da Consulta
</h4>
<p className="text-gray-700 bg-green-50 p-3 rounded-lg border-l-4 border-green-400">
{consultaSelecionada.resultados}
</p>
</div>
)}
{/* Prescrições */}
{consultaSelecionada.prescricoes && (
<div>
<h4 className="font-semibold mb-2">Prescrições Médicas</h4>
<p className="text-gray-700 bg-blue-50 p-3 rounded-lg border-l-4 border-blue-400">
{consultaSelecionada.prescricoes}
</p>
</div>
)}
{/* Próxima Consulta */}
{consultaSelecionada.proximaConsulta && (
<div>
<h4 className="font-semibold mb-2">
Próxima Consulta Recomendada
</h4>
<p className="text-gray-700 bg-yellow-50 p-3 rounded-lg border-l-4 border-yellow-400">
{consultaSelecionada.proximaConsulta}
</p>
</div>
)}
{/* Data de Criação */}
<div className="text-xs text-gray-500 pt-4 border-t">
Agendado em:{" "}
{format(
new Date(consultaSelecionada.criadoEm),
"dd/MM/yyyy às HH:mm",
{ locale: ptBR }
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AcompanhamentoPaciente;

View File

@ -3,7 +3,7 @@ import { Calendar, User, FileText, CheckCircle, LogOut } from "lucide-react";
// import consultaService from "../services/consultaService"; // não utilizado após integração com appointmentService
import { appointmentService } from "../services";
import AvailableSlotsPicker from "../components/agenda/AvailableSlotsPicker";
import medicoService from "../services/medicoService";
import { doctorService } from "../services";
import toast from "react-hot-toast";
import { format, addDays } from "date-fns";
import { ptBR } from "date-fns/locale";
@ -74,42 +74,13 @@ const AgendamentoPaciente: React.FC = () => {
try {
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
// Verificar se há token disponível
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log(
"[AgendamentoPaciente] Token disponível:",
token ? "SIM" : "NÃO"
);
if (!token) {
console.warn(
"[AgendamentoPaciente] Nenhum token encontrado - requisição pode falhar"
);
}
const doctors = await doctorService.list({ active: true });
console.log("[AgendamentoPaciente] Médicos recebidos:", doctors);
const response = await medicoService.listarMedicos({ status: "ativo" });
console.log("[AgendamentoPaciente] Resposta da API:", response);
if (!response.success) {
console.error(
"[AgendamentoPaciente] Erro na resposta:",
response.error
);
toast.error(response.error || "Erro ao carregar médicos");
return;
}
const list = response.data?.data || [];
console.log(
"[AgendamentoPaciente] Médicos recebidos:",
list.length,
list
);
const mapped: Medico[] = list.map((m) => ({
_id: m.id || Math.random().toString(36).slice(2, 9),
nome: m.nome || "",
especialidade: m.especialidade || "",
const mapped: Medico[] = doctors.map((m: any) => ({
_id: m.id,
nome: m.full_name,
especialidade: m.specialty || "",
valorConsulta: 0,
horarioAtendimento: {},
}));
@ -118,18 +89,9 @@ const AgendamentoPaciente: React.FC = () => {
setMedicos(mapped);
if (mapped.length === 0) {
if (response.error && response.error.includes("404")) {
toast.error(
"⚠️ Tabela de médicos não existe no banco de dados. Configure o Supabase primeiro.",
{
duration: 6000,
}
);
} else {
toast.error(
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
);
}
toast.error(
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
);
}
} catch (error) {
console.error("[AgendamentoPaciente] Erro ao carregar médicos:", error);
@ -161,12 +123,11 @@ const AgendamentoPaciente: React.FC = () => {
`${agendamento.data}T${agendamento.horario}:00.000Z`
);
await appointmentService.createAppointment({
await appointmentService.create({
patient_id: pacienteLogado._id,
doctor_id: agendamento.medicoId,
scheduled_at: dataHora.toISOString(),
appointment_type: "presencial",
chief_complaint: agendamento.motivoConsulta,
notes: agendamento.motivoConsulta,
});
toast.success("Consulta agendada com sucesso!");

View File

@ -0,0 +1,481 @@
import { Avatar } from "../components/ui/Avatar";
import { AvatarUpload } from "../components/ui/AvatarUpload";
import { useState } from "react";
export default function AvatarShowcase() {
const [testAvatarUrl, setTestAvatarUrl] = useState<string | undefined>(
undefined
);
type AvatarColor =
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
const colors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
"pink",
"teal",
"indigo",
"red",
];
const sampleUsers = [
{
name: "Ana Silva",
email: "ana@example.com",
avatar_url: "https://via.placeholder.com/150/0000FF/FFFFFF?text=AS",
},
{ name: "Bruno Costa", email: "bruno@example.com", avatar_url: null },
{ name: "Carla Santos", email: "carla@example.com", id: "sample-id-1" },
{ name: "Diego Ferreira", email: "diego@example.com" },
];
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-6xl mx-auto space-y-12">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Avatar Showcase
</h1>
<p className="text-gray-600">
Demonstração completa do sistema de avatares
</p>
</div>
{/* Seção 1: Tamanhos */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Tamanhos Disponíveis
</h2>
<div className="flex items-end gap-8">
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Small"
size="xs"
/>
<span className="text-xs text-gray-500">xs (24px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Small"
size="sm"
/>
<span className="text-xs text-gray-500">sm (32px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Medium"
size="md"
/>
<span className="text-xs text-gray-500">md (40px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Large"
size="lg"
/>
<span className="text-xs text-gray-500">lg (48px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Large"
size="xl"
/>
<span className="text-xs text-gray-500">xl (64px)</span>
</div>
</div>
</section>
{/* Seção 2: Cores */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Cores de Iniciais
</h2>
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{colors.map((color) => (
<div key={color} className="flex flex-col items-center gap-2">
<Avatar
name={color.charAt(0).toUpperCase() + color.slice(1)}
size="lg"
color={color}
/>
<span className="text-xs text-gray-500 capitalize">
{color}
</span>
</div>
))}
</div>
</section>
{/* Seção 3: Com e sem imagem */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Fallback de Iniciais
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{sampleUsers.map((user, index) => {
const userColors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
];
return (
<div key={index} className="flex flex-col items-center gap-3">
<Avatar
src={user.avatar_url || user.id || undefined}
name={user.name}
size="xl"
color={userColors[index % 4]}
border
/>
<div className="text-center">
<p className="font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</div>
);
})}
</div>
</section>
{/* Seção 4: Diferentes formatos de src */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Formatos de Entrada (src)
</h2>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src="https://via.placeholder.com/150/4F46E5/FFFFFF?text=URL"
name="URL String"
size="lg"
/>
<div>
<p className="font-medium">String URL</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src="https://..."
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
avatar_url:
"https://via.placeholder.com/150/10B981/FFFFFF?text=OBJ",
}}
name="Object URL"
size="lg"
/>
<div>
<p className="font-medium">Objeto com avatar_url</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{avatar_url: "..."}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
profile: {
avatar_url:
"https://via.placeholder.com/150/F59E0B/FFFFFF?text=PRF",
},
}}
name="Nested Profile"
size="lg"
/>
<div>
<p className="font-medium">Objeto aninhado com profile</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{profile: {avatar_url: "..."}}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar src={{ id: "user-123" }} name="From ID" size="lg" />
<div>
<p className="font-medium">
Gera URL do Supabase a partir do ID
</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{id: "user-123"}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar name="Sem Imagem" size="lg" color="purple" />
<div>
<p className="font-medium">Sem src (apenas iniciais)</p>
<code className="text-xs bg-white px-2 py-1 rounded">
name="Sem Imagem"
</code>
</div>
</div>
</div>
</section>
{/* Seção 5: AvatarUpload Component */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
AvatarUpload (Editável)
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Modo editável */}
<div>
<h3 className="font-medium mb-4 text-gray-700">Modo Editável</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-1"
currentAvatarUrl={testAvatarUrl}
name="Teste Usuário"
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => setTestAvatarUrl(url || undefined)}
/>
<p className="text-sm text-gray-600 text-center">
Clique no ícone da câmera para editar
</p>
</div>
</div>
{/* Modo somente leitura */}
<div>
<h3 className="font-medium mb-4 text-gray-700">
Modo Somente Leitura
</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-2"
currentAvatarUrl="https://via.placeholder.com/150/EF4444/FFFFFF?text=RO"
name="Leitura Apenas"
color="red"
size="xl"
editable={false}
/>
<p className="text-sm text-gray-600 text-center">
Sem botão de edição
</p>
</div>
</div>
</div>
</section>
{/* Seção 6: Casos de uso reais */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Casos de Uso Reais
</h2>
{/* Lista de pacientes */}
<div className="mb-6">
<h3 className="font-medium mb-3 text-gray-700">
Lista de Pacientes
</h3>
<div className="space-y-2">
{[
{ name: "Maria Oliveira", cpf: "123.456.789-00", age: 45 },
{ name: "João Santos", cpf: "987.654.321-00", age: 32 },
{ name: "Ana Costa", cpf: "456.789.123-00", age: 28 },
].map((patient, i) => {
const patientColors: AvatarColor[] = [
"blue",
"green",
"purple",
];
return (
<div
key={i}
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg transition"
>
<Avatar
name={patient.name}
size="md"
color={patientColors[i]}
/>
<div className="flex-1">
<p className="font-medium text-gray-900">
{patient.name}
</p>
<p className="text-sm text-gray-500">
CPF: {patient.cpf}
</p>
</div>
<span className="text-sm text-gray-500">
{patient.age} anos
</span>
</div>
);
})}
</div>
</div>
{/* Lista de consultas */}
<div>
<h3 className="font-medium mb-3 text-gray-700">
Lista de Consultas
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Paciente
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Médico
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Data
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{[
{
patient: "Carlos Silva",
doctor: "Dr. Pedro Lima",
specialty: "Cardiologia",
date: "20/12/2024",
status: "Confirmada",
},
{
patient: "Lucia Mendes",
doctor: "Dra. Julia Ramos",
specialty: "Pediatria",
date: "21/12/2024",
status: "Pendente",
},
].map((appointment, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.patient}
size="sm"
color="blue"
/>
<span className="text-sm text-gray-900">
{appointment.patient}
</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.doctor}
size="sm"
color="green"
/>
<div>
<p className="text-sm text-gray-900">
{appointment.doctor}
</p>
<p className="text-xs text-gray-500">
{appointment.specialty}
</p>
</div>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{appointment.date}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-full ${
appointment.status === "Confirmada"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{appointment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Seção 7: Código de exemplo */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Exemplos de Código
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Simples</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src="https://example.com/avatar.jpg"
name="João Silva"
size="md"
color="blue"
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">
Avatar com Objeto
</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src={patient} // {id: "...", avatar_url: "..."}
name={patient.full_name}
size="lg"
color="purple"
border
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Upload</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<AvatarUpload
userId={user.id}
currentAvatarUrl={user.avatar_url}
name={user.name}
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => {
// Atualizar estado
setAvatarUrl(url);
}}
/>`}
</pre>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@ -1,17 +1,13 @@
import React, { useState } from "react";
import { Stethoscope } from "lucide-react";
import { Mail, User, Phone, Stethoscope, ArrowLeft } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import userService from "../services/userService";
import { userService } from "../services";
const CadastroMedico: React.FC = () => {
const [formData, setFormData] = useState({
nome: "",
email: "",
senha: "",
confirmarSenha: "",
especialidade: "",
crm: "",
telefone: "",
});
const [loading, setLoading] = useState(false);
@ -19,199 +15,182 @@ const CadastroMedico: React.FC = () => {
const handleCadastro = async (e: React.FormEvent) => {
e.preventDefault();
// Validações básicas
if (!formData.nome.trim()) {
toast.error("Informe o nome completo");
return;
}
if (!formData.crm.trim() || formData.crm.trim().length < 4) {
toast.error("CRM inválido");
return;
}
if (!formData.especialidade.trim()) {
toast.error("Informe a especialidade");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
toast.error("Email inválido");
return;
}
if (!formData.telefone.trim()) {
toast.error("Informe o telefone");
return;
}
if (formData.senha !== formData.confirmarSenha) {
toast.error("As senhas não coincidem");
return;
}
if (formData.senha.length < 6) {
toast.error("A senha deve ter pelo menos 6 caracteres");
return;
}
setLoading(true);
try {
const result = await userService.createMedico({
nome: formData.nome,
email: formData.email,
password: formData.senha,
telefone: formData.telefone,
});
if (!result.success) {
toast.error(result.error || "Erro ao cadastrar médico");
// Validações básicas
if (!formData.nome.trim()) {
toast.error("Nome completo é obrigatório");
setLoading(false);
return;
}
toast.success("Cadastro realizado com sucesso!");
navigate("/login-medico");
} catch {
toast.error("Erro ao cadastrar médico. Tente novamente.");
if (!formData.email.trim() || !formData.email.includes("@")) {
toast.error("Email válido é obrigatório");
setLoading(false);
return;
}
// Usar create-user (flexível, validações mínimas)
// Cria entrada básica em doctors (sem CRM, CPF vazios)
console.log("[CadastroMedico] Enviando dados para create-user:", {
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "medico",
});
const response = await userService.createUser(
{
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "medico",
},
true
); // true = registro público (sem token)
console.log("[CadastroMedico] Resposta do create-user:", response);
toast.success(
"Cadastro realizado com sucesso! Verifique seu email para ativar a conta. Complete seu perfil depois de ativar.",
{ duration: 6000 }
);
// Limpa formulário e volta para login
setFormData({ nome: "", email: "", telefone: "" });
setTimeout(() => navigate("/login-medico"), 2000);
} catch (error: unknown) {
console.error("Erro ao cadastrar médico:", error);
const errorMsg =
(error as any)?.response?.data?.error ||
(error as Error)?.message ||
"Erro ao criar conta";
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
return (
<div className="relative min-h-screen flex items-center justify-center p-4">
{/* Full-viewport background for this page only */}
<div
className="fixed inset-0 bg-white dark:bg-black transition-colors pointer-events-none"
aria-hidden="true"
/>
<div className="relative max-w-md w-full">
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4 transition-colors">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="bg-gradient-to-r from-indigo-600 to-indigo-400 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<div className="bg-gradient-to-r from-indigo-600 to-indigo-400 dark:from-indigo-700 dark:to-indigo-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 shadow-md">
<Stethoscope className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Cadastro de Médico
</h1>
<p className="text-gray-600">
Preencha os dados para cadastrar um novo médico
<p className="text-gray-600 dark:text-gray-400">
Crie sua conta para acessar o sistema
</p>
</div>
<div className="bg-white rounded-lg shadow-lg p-8">
<form onSubmit={handleCadastro} className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 border border-transparent dark:border-gray-700 transition-colors">
<form onSubmit={handleCadastro} className="space-y-6" noValidate>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome Completo
<label
htmlFor="nome"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Nome Completo *
</label>
<input
type="text"
value={formData.nome}
onChange={(e) =>
setFormData((prev) => ({ ...prev, nome: e.target.value }))
}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<input
type="tel"
value={formData.telefone}
onChange={(e) =>
setFormData((prev) => ({ ...prev, telefone: e.target.value }))
}
placeholder="(11) 99999-9999"
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CRM
</label>
<input
type="text"
value={formData.crm}
onChange={(e) =>
setFormData((prev) => ({ ...prev, crm: e.target.value }))
}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Especialidade
</label>
<input
type="text"
value={formData.especialidade}
onChange={(e) =>
setFormData((prev) => ({
...prev,
especialidade: e.target.value,
}))
}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
className="form-input"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Senha
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={formData.senha}
id="nome"
type="text"
value={formData.nome}
onChange={(e) =>
setFormData((prev) => ({ ...prev, senha: e.target.value }))
setFormData((prev) => ({ ...prev, nome: e.target.value }))
}
className="form-input"
minLength={6}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Dr. Seu Nome"
required
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirmar Senha
</label>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={formData.confirmarSenha}
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="seu@email.com"
required
autoComplete="email"
/>
</div>
</div>
<div>
<label
htmlFor="telefone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Telefone (Opcional)
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="telefone"
type="tel"
value={formData.telefone}
onChange={(e) =>
setFormData((prev) => ({
...prev,
confirmarSenha: e.target.value,
telefone: e.target.value,
}))
}
className="form-input"
required
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="(00) 00000-0000"
autoComplete="tel"
/>
</div>
</div>
<div className="flex space-x-4">
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg space-y-2">
<p className="text-sm text-blue-800 dark:text-blue-200">
🔐 <strong>Ativação por Email:</strong> Você receberá um link
mágico (magic link) no seu email para ativar a conta e definir
sua senha.
</p>
<p className="text-sm text-blue-800 dark:text-blue-200">
📋 <strong>Completar Perfil:</strong> Após ativar, você poderá
adicionar CRM, CPF, especialidade e outros dados no seu perfil.
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-indigo-600 to-indigo-400 text-white py-3 px-4 rounded-lg font-medium hover:from-indigo-700 hover:to-indigo-500 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
{loading ? "Criando conta..." : "Criar Conta"}
</button>
<div className="text-center">
<button
type="button"
onClick={() => navigate("/login-medico")}
className="flex-1 bg-gray-100 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-200 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2"
className="inline-flex items-center gap-2 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm transition-colors"
>
Voltar
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-400 text-white py-3 px-4 rounded-lg font-medium hover:from-indigo-700 hover:to-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
{loading ? "Cadastrando..." : "Cadastrar"}
<ArrowLeft className="w-4 h-4" />
Voltar para o login
</button>
</div>
</form>

View File

@ -1,406 +0,0 @@
import React, { useState } from "react";
import { UserPlus } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import userService from "../services/userService";
import { buscarEnderecoViaCEP } from "../services/pacienteService";
const INITIAL_STATE = {
nome: "",
email: "",
senha: "",
confirmarSenha: "",
cpf: "",
telefone: "",
dataNascimento: "",
cep: "",
rua: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
};
const CadastroPaciente: React.FC = () => {
const [form, setForm] = useState(INITIAL_STATE);
const [loading, setLoading] = useState(false);
const [autoEndereco, setAutoEndereco] = useState(false);
const navigate = useNavigate();
const update = (patch: Partial<typeof INITIAL_STATE>) =>
setForm((prev) => ({ ...prev, ...patch }));
const handleBuscarCEP = async () => {
const clean = form.cep.replace(/\D/g, "");
if (clean.length !== 8) {
toast.error("CEP inválido");
return;
}
try {
const end = await buscarEnderecoViaCEP(clean);
if (!end) {
toast.error("CEP não encontrado");
return;
}
update({
rua: end.rua || "",
bairro: end.bairro || "",
cidade: end.cidade || "",
estado: end.estado || "",
});
setAutoEndereco(true);
} catch {
toast.error("Falha ao buscar CEP");
}
};
const validate = (): boolean => {
if (!form.nome.trim()) {
toast.error("Nome é obrigatório");
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
toast.error("Email inválido");
return false;
}
if (!form.cpf.trim() || form.cpf.replace(/\D/g, "").length < 11) {
toast.error("CPF inválido");
return false;
}
if (!form.telefone.trim()) {
toast.error("Telefone é obrigatório");
return false;
}
if (!form.senha || form.senha.length < 6) {
toast.error("Senha mínima 6 caracteres");
return false;
}
if (form.senha !== form.confirmarSenha) {
toast.error("As senhas não coincidem");
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
console.log("[CadastroPaciente] Iniciando cadastro via API Supabase...");
// ETAPA 1: Criar usuário no Supabase Auth (gera token JWT)
console.log("[CadastroPaciente] Criando usuário de autenticação...");
const result = await userService.signupPaciente({
nome: form.nome,
email: form.email,
password: form.senha,
telefone: form.telefone,
cpf: form.cpf,
dataNascimento: form.dataNascimento,
endereco: {
cep: form.cep,
rua: form.rua,
numero: form.numero,
complemento: form.complemento,
bairro: form.bairro,
cidade: form.cidade,
estado: form.estado,
},
});
if (!result.success) {
console.error("[CadastroPaciente] Erro no cadastro:", result.error);
toast.error(result.error || "Erro ao cadastrar paciente");
return;
}
const userId = result.data?.id;
console.log("[CadastroPaciente] Usuário criado com sucesso! ID:", userId);
// ETAPA 2: Criar registro de paciente usando token JWT do signup
console.log("[CadastroPaciente] Criando registro de paciente na API...");
const { createPatient } = await import("../services/pacienteService");
const pacienteResult = await createPatient({
nome: form.nome,
email: form.email,
telefone: form.telefone,
cpf: form.cpf,
dataNascimento: form.dataNascimento,
endereco: {
rua: form.rua,
numero: form.numero,
complemento: form.complemento,
bairro: form.bairro,
cidade: form.cidade,
estado: form.estado,
cep: form.cep,
},
});
if (!pacienteResult.success) {
console.error(
"[CadastroPaciente] Erro ao criar paciente:",
pacienteResult.error
);
console.log(
"[CadastroPaciente] Usuário criado mas dados do paciente não foram salvos completamente"
);
// Não mostra erro para o usuário - ele pode fazer login mesmo assim
} else {
console.log(
"[CadastroPaciente] Paciente criado com sucesso!",
pacienteResult.data
);
}
toast.success(
"Paciente cadastrado com sucesso! Faça login para acessar."
);
navigate("/paciente");
} catch (error) {
console.error("[CadastroPaciente] Erro inesperado:", error);
toast.error("Erro inesperado ao cadastrar");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white flex items-center justify-center p-4">
<div className="max-w-xl w-full">
<div className="text-center mb-8">
<div className="bg-gradient-to-r from-blue-600 to-blue-400 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<UserPlus className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Cadastro de Paciente
</h1>
<p className="text-gray-600">
Crie sua conta para acessar o acompanhamento e agendamentos.
</p>
</div>
<div className="bg-white rounded-lg shadow-lg p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome Completo
</label>
<input
type="text"
value={form.nome}
onChange={(e) => update({ nome: e.target.value })}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data de Nascimento
</label>
<input
type="date"
value={form.dataNascimento}
onChange={(e) => update({ dataNascimento: e.target.value })}
className="form-input"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CPF
</label>
<input
type="text"
value={form.cpf}
onChange={(e) => update({ cpf: e.target.value })}
placeholder="000.000.000-00"
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<input
type="tel"
value={form.telefone}
onChange={(e) => update({ telefone: e.target.value })}
placeholder="(11) 99999-9999"
className="form-input"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => update({ email: e.target.value })}
className="form-input"
required
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Senha
</label>
<input
type="password"
value={form.senha}
onChange={(e) => update({ senha: e.target.value })}
className="form-input"
minLength={6}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirmar Senha
</label>
<input
type="password"
value={form.confirmarSenha}
onChange={(e) => update({ confirmarSenha: e.target.value })}
className="form-input"
required
/>
</div>
</div>
{/* Endereço opcional */}
<div className="pt-2 border-t">
<h2 className="text-sm font-semibold text-gray-600 mb-2">
Endereço (opcional)
</h2>
<div className="grid md:grid-cols-6 gap-4">
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
CEP
</label>
<div className="flex gap-2">
<input
type="text"
value={form.cep}
onChange={(e) => {
setAutoEndereco(false);
update({ cep: e.target.value });
}}
className="form-input"
placeholder="00000000"
/>
<button
type="button"
onClick={handleBuscarCEP}
className="px-3 py-2 text-xs rounded-md bg-blue-100 hover:bg-blue-200 text-blue-700 font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
Buscar
</button>
</div>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-gray-600 mb-1">
Rua
</label>
<input
type="text"
value={form.rua}
onChange={(e) => update({ rua: e.target.value })}
className="form-input"
disabled={autoEndereco}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Número
</label>
<input
type="text"
value={form.numero}
onChange={(e) => update({ numero: e.target.value })}
className="form-input"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Bairro
</label>
<input
type="text"
value={form.bairro}
onChange={(e) => update({ bairro: e.target.value })}
className="form-input"
disabled={autoEndereco}
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Cidade
</label>
<input
type="text"
value={form.cidade}
onChange={(e) => update({ cidade: e.target.value })}
className="form-input"
disabled={autoEndereco}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Estado
</label>
<input
type="text"
value={form.estado}
onChange={(e) => update({ estado: e.target.value })}
className="form-input"
disabled={autoEndereco}
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-gray-600 mb-1">
Complemento
</label>
<input
type="text"
value={form.complemento}
onChange={(e) => update({ complemento: e.target.value })}
className="form-input"
/>
</div>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => navigate("/paciente")}
className="flex-1 bg-gray-100 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-200 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2"
>
Voltar
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-400 text-white py-3 px-4 rounded-lg font-medium hover:from-blue-700 hover:to-blue-500 disabled:opacity-50 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
{loading ? "Cadastrando..." : "Cadastrar"}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CadastroPaciente;

View File

@ -1,827 +1,182 @@
import React, { useState, useEffect } from "react";
import {
Users,
UserPlus,
Search,
Edit,
Trash2,
Phone,
Mail,
MapPin,
FileText,
Activity,
} from "lucide-react";
import {
listPatients,
createPatient,
updatePatient,
deletePatient,
} from "../services/pacienteService";
import userService from "../services/userService";
import React, { useState } from "react";
import { Mail, Lock, User, Phone, Clipboard, ArrowLeft } from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
// import { ptBR } from 'date-fns/locale' // Removido, não utilizado
interface Paciente {
_id: string;
nome: string;
cpf?: string;
telefone?: string;
email?: string;
dataNascimento?: string;
altura?: number;
peso?: number;
endereco?: {
rua?: string;
numero?: string;
bairro?: string;
cidade?: string;
cep?: string;
};
convenio?: string;
numeroCarteirinha?: string;
observacoes?: string | null;
ativo?: boolean;
criadoEm?: string;
}
import { useNavigate } from "react-router-dom";
import { userService } from "../services";
const CadastroSecretaria: React.FC = () => {
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingPaciente, setEditingPaciente] = useState<Paciente | null>(null);
const [formData, setFormData] = useState({
nome: "",
cpf: "",
telefone: "",
email: "",
dataNascimento: "",
altura: "",
peso: "",
endereco: {
rua: "",
numero: "",
bairro: "",
cidade: "",
cep: "",
},
convenio: "",
numeroCarteirinha: "",
observacoes: "",
telefone: "",
});
// Função para carregar pacientes
const carregarPacientes = async () => {
try {
setLoading(true);
const pacientesApi = await listPatients();
setPacientes(
pacientesApi.data.map((p) => ({
_id: p.id,
nome: p.nome,
cpf: p.cpf,
telefone: p.telefone,
email: p.email,
dataNascimento: p.dataNascimento,
altura: p.alturaM ? Math.round(p.alturaM * 100) : undefined,
peso: p.pesoKg,
endereco: {
rua: p.endereco?.rua,
numero: p.endereco?.numero,
bairro: p.endereco?.bairro,
cidade: p.endereco?.cidade,
cep: p.endereco?.cep,
},
convenio: p.convenio,
numeroCarteirinha: p.numeroCarteirinha,
observacoes: p.observacoes || undefined,
criadoEm: p.created_at,
}))
);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar lista de pacientes");
} finally {
setLoading(false);
}
};
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
useEffect(() => {
carregarPacientes();
}, []);
const calcularIMC = (altura?: number, peso?: number) => {
if (!altura || !peso) return null;
const alturaMetros = altura / 100;
const imc = peso / (alturaMetros * alturaMetros);
return imc.toFixed(1);
};
const getIMCStatus = (imc: number) => {
if (imc < 18.5) return { status: "Abaixo do peso", color: "text-blue-600" };
if (imc < 25) return { status: "Peso normal", color: "text-green-600" };
if (imc < 30) return { status: "Sobrepeso", color: "text-yellow-600" };
return { status: "Obesidade", color: "text-red-600" };
};
const handleSubmit = async (e: React.FormEvent) => {
const handleCadastro = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
setLoading(true);
// NOTE: remote CPF validation removed to avoid false negatives
// NOTE: remote CEP validation removed to avoid false negatives
const pacienteData = {
...formData,
altura: formData.altura ? parseFloat(formData.altura) : undefined,
peso: formData.peso ? parseFloat(formData.peso) : undefined,
ativo: true,
criadoPor: "secretaria",
criadoEm: new Date().toISOString(),
atualizadoEm: new Date().toISOString(),
};
if (editingPaciente) {
await updatePatient(editingPaciente._id, pacienteData);
toast.success("Paciente atualizado com sucesso!");
} else {
await createPatient(pacienteData);
toast.success("Paciente cadastrado com sucesso!");
// Validações básicas
if (!formData.nome.trim()) {
toast.error("Nome completo é obrigatório");
setLoading(false);
return;
}
// (Refactor) Criação de secretária via fluxo real se condição atender (mantendo lógica anterior condicional)
// OBS: Este bloco antes criava secretária mock ao cadastrar um novo paciente.
// Caso essa associação não faça sentido de negócio, remover todo o bloco abaixo posteriormente.
if (!editingPaciente && formData.email && formData.nome) {
try {
// Gera senha temporária segura simples; idealmente backend enviaria email de reset.
const tempPassword = Math.random().toString(36).slice(-10) + "!A1";
const secResp = await userService.createSecretaria({
nome: formData.nome,
email: formData.email,
password: tempPassword,
telefone: formData.telefone,
});
if (secResp.success) {
toast.success(
"Secretária criada (fluxo real). Senha temporária gerada."
);
console.info(
"[CadastroSecretaria] Secretária criada: ",
secResp.data?.id
);
} else {
// Não bloquear fluxo principal de paciente
toast.error(
"Falha ao criar secretária (fluxo real): " +
(secResp.error || "erro desconhecido")
);
}
} catch (err) {
console.warn("Falha inesperada ao criar secretária:", err);
toast.error("Erro inesperado ao criar secretária");
}
if (!formData.email.trim() || !formData.email.includes("@")) {
toast.error("Email válido é obrigatório");
setLoading(false);
return;
}
// resetForm removido, não existe
setEditingPaciente(null);
setShowForm(false);
} catch (error) {
console.error("Erro ao salvar paciente:", error);
toast.error("Erro ao salvar paciente. Tente novamente.");
// Usar create-user (flexível, validações mínimas)
await userService.createUser({
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "secretaria",
});
toast.success(
"Cadastro realizado com sucesso! Verifique seu email para ativar a conta.",
{ duration: 5000 }
);
// Limpa formulário e volta para login
setFormData({ nome: "", email: "", telefone: "" });
setTimeout(() => navigate("/login-secretaria"), 2000);
} catch (error: any) {
console.error("Erro ao cadastrar secretária:", error);
const errorMsg =
error?.response?.data?.error || error?.message || "Erro ao criar conta";
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleEdit = (paciente: Paciente) => {
setFormData({
nome: paciente.nome || "",
cpf: paciente.cpf || "",
telefone: paciente.telefone || "",
email: paciente.email || "",
dataNascimento: paciente.dataNascimento
? paciente.dataNascimento.split("T")[0]
: "",
altura: paciente.altura?.toString() || "",
peso: paciente.peso?.toString() || "",
endereco: {
rua: paciente.endereco?.rua || "",
numero: paciente.endereco?.numero || "",
bairro: paciente.endereco?.bairro || "",
cidade: paciente.endereco?.cidade || "",
cep: paciente.endereco?.cep || "",
},
convenio: paciente.convenio || "",
numeroCarteirinha: paciente.numeroCarteirinha || "",
observacoes: paciente.observacoes || "",
});
setEditingPaciente(paciente);
setShowForm(true);
};
const handleDelete = async (pacienteId: string) => {
if (window.confirm("Tem certeza que deseja excluir este paciente?")) {
try {
await deletePatient(pacienteId);
toast.success("Paciente removido com sucesso!");
carregarPacientes();
} catch (error) {
console.error("Erro ao remover paciente:", error);
toast.error("Erro ao remover paciente");
}
}
};
const filteredPacientes = pacientes.filter(
(paciente) =>
(paciente.nome || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(paciente.cpf || "").includes(searchTerm) ||
(paciente.telefone || "").includes(searchTerm)
);
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Cadastro de Pacientes
<div className="min-h-screen bg-gradient-to-br from-green-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4 transition-colors">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="bg-gradient-to-r from-green-600 to-green-400 dark:from-green-700 dark:to-green-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 shadow-md">
<Clipboard className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Cadastro de Secretária
</h1>
<p className="text-gray-600">
Gerencie o cadastro de pacientes da clínica
<p className="text-gray-600 dark:text-gray-400">
Crie sua conta para acessar o sistema
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="btn-primary mt-4 md:mt-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
<UserPlus className="w-5 h-5 mr-2" />
Novo Paciente
</button>
</div>
{/* Estatísticas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-gradient-to-l from-blue-700 to-blue-400 rounded-full">
<Users className="w-6 h-6 text-white" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Total de Pacientes
</p>
<p className="text-2xl font-bold text-gray-900">
{pacientes.length}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-green-100 rounded-full">
<FileText className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Com Convênio</p>
<p className="text-2xl font-bold text-gray-900">
{
pacientes.filter(
(p) => p.convenio && p.convenio !== "Particular"
).length
}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-purple-100 rounded-full">
<UserPlus className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Cadastros Hoje
</p>
<p className="text-2xl font-bold text-gray-900">
{
pacientes.filter((p) => {
const hoje = new Date().toISOString().split("T")[0];
return p.criadoEm?.startsWith(hoje);
}).length
}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center">
<div className="p-3 bg-orange-100 rounded-full">
<Activity className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Com Dados Físicos
</p>
<p className="text-2xl font-bold text-gray-900">
{pacientes.filter((p) => p.altura && p.peso).length}
</p>
</div>
</div>
</div>
</div>
{/* Busca */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Buscar por nome, CPF ou telefone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 form-input"
/>
</div>
</div>
{/* Lista de Pacientes */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 z-10 bg-gradient-to-l from-blue-700 to-blue-400">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Paciente
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Contato
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Dados Físicos
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Convênio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredPacientes.map((paciente) => {
const imc = calcularIMC(paciente.altura, paciente.peso);
const imcStatus = imc ? getIMCStatus(parseFloat(imc)) : null;
return (
<tr
key={paciente._id}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100"
>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{paciente.nome || "Nome não informado"}
</div>
<div className="text-sm text-gray-500">
CPF: {paciente.cpf || "Não informado"}
</div>
<div className="text-sm text-gray-500">
Nascimento:{" "}
{paciente.dataNascimento
? format(
new Date(paciente.dataNascimento),
"dd/MM/yyyy"
)
: "Não informado"}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
<div className="flex items-center text-sm text-gray-900">
<Phone className="w-4 h-4 mr-2 text-gray-400" />
{paciente.telefone || "Não informado"}
</div>
<div className="flex items-center text-sm text-gray-900">
<Mail className="w-4 h-4 mr-2 text-gray-400" />
{paciente.email || "Não informado"}
</div>
<div className="flex items-center text-sm text-gray-500">
<MapPin className="w-4 h-4 mr-2 text-gray-400" />
{paciente.endereco?.cidade ||
"Cidade não informada"}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
{paciente.altura && (
<div className="text-sm text-gray-900">
Altura: {paciente.altura} cm
</div>
)}
{paciente.peso && (
<div className="text-sm text-gray-900">
Peso: {paciente.peso} kg
</div>
)}
{imc && imcStatus && (
<div className="text-sm">
<span className="text-gray-600">IMC: </span>
<span
className={`font-medium ${imcStatus.color}`}
>
{imc} ({imcStatus.status})
</span>
</div>
)}
{!paciente.altura && !paciente.peso && (
<div className="text-sm text-gray-400">
Dados não informados
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{paciente.convenio || "Não informado"}
</div>
{paciente.numeroCarteirinha && (
<div className="text-sm text-gray-500">
Carteirinha: {paciente.numeroCarteirinha}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleEdit(paciente)}
className="inline-flex items-center p-1.5 rounded-lg text-blue-600 hover:text-blue-900 hover:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
aria-label="Editar paciente"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(paciente._id)}
className="inline-flex items-center p-1.5 rounded-lg text-red-600 hover:text-red-900 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-red-500"
aria-label="Excluir paciente"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Modal de Formulário */}
{showForm && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="cadastro-secretaria-title"
>
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h3
id="cadastro-secretaria-title"
className="text-lg font-semibold mb-6"
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 border border-transparent dark:border-gray-700 transition-colors">
<form onSubmit={handleCadastro} className="space-y-6" noValidate>
<div>
<label
htmlFor="nome"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{editingPaciente ? "Editar Paciente" : "Novo Paciente"}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Nome */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nome Completo
</label>
<input
type="text"
value={formData.nome}
onChange={(e) =>
setFormData({ ...formData, nome: e.target.value })
}
className="form-input"
required
/>
</div>
{/* CPF com máscara */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CPF
</label>
<input
type="text"
value={formData.cpf}
onChange={(e) => {
let v = e.target.value.replace(/\D/g, "");
if (v.length > 11) v = v.slice(0, 11);
v = v.replace(/(\d{3})(\d)/, "$1.$2");
v = v.replace(/(\d{3})(\d)/, "$1.$2");
v = v.replace(/(\d{3})(\d{1,2})$/, "$1-$2");
setFormData({ ...formData, cpf: v });
}}
className="form-input"
placeholder="000.000.000-00"
required
/>
</div>
{/* Telefone com máscara internacional */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Telefone
</label>
<input
type="tel"
value={formData.telefone}
onChange={(e) => {
let v = e.target.value.replace(/\D/g, "");
if (v.length > 13) v = v.slice(0, 13);
if (v.length >= 2) v = "+55 " + v;
if (v.length >= 4)
v = v.replace(/(\+55 )(\d{2})(\d)/, "$1$2 $3");
if (v.length >= 9)
v = v.replace(
/(\+55 \d{2} )(\d{5})(\d{4})/,
"$1$2-$3"
);
setFormData({ ...formData, telefone: v });
}}
className="form-input"
placeholder="+55 XX XXXXX-XXXX"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data de Nascimento
</label>
<input
type="date"
value={formData.dataNascimento}
onChange={(e) =>
setFormData({
...formData,
dataNascimento: e.target.value,
})
}
className="form-input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Altura (cm)
</label>
<input
type="number"
min="50"
max="250"
step="0.1"
value={formData.altura}
onChange={(e) =>
setFormData({ ...formData, altura: e.target.value })
}
className="form-input"
placeholder="Ex: 170"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Peso (kg)
</label>
<input
type="number"
min="10"
max="300"
step="0.1"
value={formData.peso}
onChange={(e) =>
setFormData({ ...formData, peso: e.target.value })
}
className="form-input"
placeholder="Ex: 70.5"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CEP
</label>
<input
type="text"
value={formData.endereco.cep}
onChange={(e) =>
setFormData({
...formData,
endereco: {
...formData.endereco,
cep: e.target.value,
},
})
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rua
</label>
<input
type="text"
value={formData.endereco.rua}
onChange={(e) =>
setFormData({
...formData,
endereco: {
...formData.endereco,
rua: e.target.value,
},
})
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Número
</label>
<input
type="text"
value={formData.endereco.numero}
onChange={(e) =>
setFormData({
...formData,
endereco: {
...formData.endereco,
numero: e.target.value,
},
})
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bairro
</label>
<input
type="text"
value={formData.endereco.bairro}
onChange={(e) =>
setFormData({
...formData,
endereco: {
...formData.endereco,
bairro: e.target.value,
},
})
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cidade
</label>
<input
type="text"
value={formData.endereco.cidade}
onChange={(e) =>
setFormData({
...formData,
endereco: {
...formData.endereco,
cidade: e.target.value,
},
})
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Convênio
</label>
<select
value={formData.convenio}
onChange={(e) =>
setFormData({ ...formData, convenio: e.target.value })
}
className="form-input"
>
<option value="">Selecione</option>
<option value="Particular">Particular</option>
<option value="Unimed">Unimed</option>
<option value="SulAmérica">SulAmérica</option>
<option value="Bradesco Saúde">Bradesco Saúde</option>
<option value="Amil">Amil</option>
<option value="NotreDame">NotreDame</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Número da Carteirinha
</label>
<input
type="text"
value={formData.numeroCarteirinha}
onChange={(e) =>
setFormData({
...formData,
numeroCarteirinha: e.target.value,
})
}
className="form-input"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Observações
</label>
<textarea
value={formData.observacoes}
onChange={(e) =>
setFormData({ ...formData, observacoes: e.target.value })
}
className="form-input"
rows={3}
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
// onClick removido, resetForm não existe
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="btn-primary disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
{loading
? "Salvando..."
: editingPaciente
? "Atualizar"
: "Cadastrar"}
</button>
</div>
</form>
Nome Completo *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="nome"
type="text"
value={formData.nome}
onChange={(e) =>
setFormData((prev) => ({ ...prev, nome: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Seu nome completo"
required
autoComplete="name"
/>
</div>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="seu@email.com"
required
autoComplete="email"
/>
</div>
</div>
<div>
<label
htmlFor="telefone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Telefone (Opcional)
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="telefone"
type="tel"
value={formData.telefone}
onChange={(e) =>
setFormData((prev) => ({
...prev,
telefone: e.target.value,
}))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="(00) 00000-0000"
autoComplete="tel"
/>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
🔐 <strong>Ativação por Email:</strong> Você receberá um link
mágico (magic link) no seu email para ativar a conta e definir
sua senha.
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-green-600 to-green-400 text-white py-3 px-4 rounded-lg font-medium hover:from-green-700 hover:to-green-500 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
{loading ? "Criando conta..." : "Criar Conta"}
</button>
<div className="text-center">
<button
type="button"
onClick={() => navigate("/login-secretaria")}
className="inline-flex items-center gap-2 text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium text-sm transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Voltar para o login
</button>
</div>
</form>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import React, { useEffect } from "react";
const ClearCache: React.FC = () => {
useEffect(() => {
console.log("🧹 Limpando TUDO...");
// Limpar localStorage
localStorage.clear();
console.log("✅ localStorage limpo");
// Limpar sessionStorage
sessionStorage.clear();
console.log("✅ sessionStorage limpo");
// Limpar cookies
document.cookie.split(";").forEach((c) => {
document.cookie = c
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
console.log("✅ Cookies limpos");
// Aguardar 1 segundo e redirecionar
setTimeout(() => {
console.log("🔄 Redirecionando para home...");
window.location.href = "/";
}, 1000);
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="text-center p-8 bg-white rounded-xl shadow-2xl max-w-md">
<div className="mb-6">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mx-auto"></div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
🧹 Limpando Cache
</h1>
<p className="text-gray-600 mb-4">
Removendo todas as sessões e dados armazenados...
</p>
<div className="space-y-2 text-sm text-gray-500">
<p> localStorage</p>
<p> sessionStorage</p>
<p> Cookies</p>
</div>
<p className="mt-6 text-xs text-gray-400">
Você será redirecionado em instantes...
</p>
</div>
</div>
);
};
export default ClearCache;

View File

@ -1,12 +1,10 @@
import React, { useState, useEffect } from "react";
import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { listPatients } from "../services/pacienteService";
import medicoService from "../services/medicoService";
import consultaService from "../services/consultaService";
import { useNavigate, useSearchParams } from "react-router-dom";
import { patientService, doctorService, appointmentService } from "../services";
import { MetricCard } from "../components/MetricCard";
import { i18n } from "../i18n";
import { telemetry } from "../services/telemetry";
import { useAuth } from "../hooks/useAuth";
const Home: React.FC = () => {
const [stats, setStats] = useState({
@ -18,56 +16,81 @@ const Home: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user } = useAuth();
// Limpar cache se houver parâmetro ?clear=true
useEffect(() => {
if (searchParams.get("clear") === "true") {
console.log("🧹 Limpando cache via URL...");
localStorage.clear();
sessionStorage.clear();
// Remove o parâmetro da URL e recarrega
window.location.href = "/";
}
}, [searchParams]);
useEffect(() => {
fetchStats();
}, []);
// Só buscar estatísticas se o usuário estiver autenticado
if (user) {
fetchStats();
} else {
setLoading(false);
}
}, [user]);
const fetchStats = async () => {
try {
setLoading(true);
setError(false);
const [pacientesResult, medicosResult, consultasResult] =
await Promise.all([
listPatients().catch(() => ({ data: [] })),
medicoService.listarMedicos().catch(() => ({ data: { data: [] } })),
consultaService
.listarConsultas()
.catch(() => ({ data: { data: [] } })),
]);
// Silenciar erros 401 (não autenticado) - são esperados na home pública
const [pacientes, medicos, consultasRaw] = await Promise.all([
patientService.list().catch((err) => {
if (err.response?.status !== 401)
console.error("Erro ao buscar pacientes:", err);
return [];
}),
doctorService.list().catch((err) => {
if (err.response?.status !== 401)
console.error("Erro ao buscar médicos:", err);
return [];
}),
appointmentService.list().catch((err) => {
if (err.response?.status !== 401)
console.error("Erro ao buscar consultas:", err);
return [];
}),
]);
// Ensure consultas is an array
const consultas = Array.isArray(consultasRaw) ? consultasRaw : [];
const hoje = new Date().toISOString().split("T")[0];
const consultas = consultasResult.data?.data || [];
const consultasHoje =
consultas.filter((consulta) => consulta.data_hora?.startsWith(hoje))
.length || 0;
const consultasHoje = consultas.filter((c) =>
c.scheduled_at?.startsWith(hoje)
).length;
const consultasPendentes =
consultas.filter(
(consulta) =>
consulta.status === "agendada" || consulta.status === "confirmada"
).length || 0;
const medicos = medicosResult.data?.data || [];
const consultasPendentes = consultas.filter(
(c) => c.status === "requested" || c.status === "confirmed"
).length;
setStats({
totalPacientes: pacientesResult.data?.length || 0,
totalMedicos: medicos.length || 0,
totalPacientes: pacientes.length,
totalMedicos: medicos.length,
consultasHoje,
consultasPendentes,
});
} catch (err) {
console.error("Erro ao carregar estatísticas:", err);
setError(true);
telemetry.trackError("stats_load_error", String(err));
} finally {
setLoading(false);
}
};
const handleCTA = (action: string, destination: string) => {
telemetry.trackCTA(action, destination);
console.log(`CTA clicked: ${action} -> ${destination}`);
navigate(destination);
};

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import AvatarInitials from "../components/AvatarInitials";
import { Stethoscope, Mail, Phone, AlertTriangle } from "lucide-react";
import medicoService, { MedicoDetalhado } from "../services/medicoService";
import { doctorService } from "../services";
const ListaMedicos: React.FC = () => {
const [medicos, setMedicos] = useState<MedicoDetalhado[]>([]);
@ -14,7 +14,7 @@ const ListaMedicos: React.FC = () => {
setLoading(true);
setError(null);
try {
const resp = await medicoService.listarMedicos({ status: "ativo" });
const resp = await doctorService.listarMedicos({ status: "ativo" });
if (!resp.success) {
if (!cancelled) {
setError(resp.error || "Falha ao carregar médicos");

View File

@ -24,12 +24,10 @@ function formatEmail(email?: string) {
return email.trim().toLowerCase();
}
import { Users, Mail, Phone } from "lucide-react";
import {
listPatients,
type Paciente as PacienteApi,
} from "../services/pacienteService";
import { patientService } from "../services/index";
import type { Patient } from "../services/patients/types";
type Paciente = PacienteApi;
type Paciente = Patient;
const ListaPacientes: React.FC = () => {
const [pacientes, setPacientes] = useState<Paciente[]>([]);
@ -41,12 +39,10 @@ const ListaPacientes: React.FC = () => {
setLoading(true);
setError(null);
try {
const resp = await listPatients();
const items = resp.data;
const items = await patientService.list();
if (!items.length) {
console.warn(
'[ListaPacientes] Nenhum paciente retornado. Verifique se a tabela "patients" possui registros ou se variáveis VITE_SUPABASE_URL / KEY apontam para produção. fromCache=',
resp.fromCache
'[ListaPacientes] Nenhum paciente retornado. Verifique se a tabela "patients" possui registros.'
);
}
setPacientes(items as Paciente[]);
@ -86,17 +82,11 @@ const ListaPacientes: React.FC = () => {
tabIndex={0}
>
<div className="flex items-center gap-2 mb-2">
{paciente.avatar_url ? (
<img
src={paciente.avatar_url}
alt={paciente.nome}
className="h-10 w-10 rounded-full object-cover border"
/>
) : (
<AvatarInitials name={paciente.nome} size={40} />
)}
<AvatarInitials name={paciente.full_name} size={40} />
<Users className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-lg">{paciente.nome}</span>
<span className="font-semibold text-lg">
{paciente.full_name}
</span>
</div>
<div className="text-sm text-gray-700">
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
@ -105,12 +95,13 @@ const ListaPacientes: React.FC = () => {
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4" /> {formatPhone(paciente.telefone)}
<Phone className="w-4 h-4" />{" "}
{formatPhone(paciente.phone_mobile)}
</div>
<div className="text-xs text-gray-500">
Nascimento:{" "}
{paciente.dataNascimento
? new Date(paciente.dataNascimento).toLocaleDateString()
{paciente.birth_date
? new Date(paciente.birth_date).toLocaleDateString()
: "Não informado"}
</div>
</div>

View File

@ -3,6 +3,7 @@ import { Mail, Lock, Stethoscope } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService } from "../services";
const LoginMedico: React.FC = () => {
const [formData, setFormData] = useState({
@ -14,14 +15,6 @@ const LoginMedico: React.FC = () => {
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
// Credenciais fixas para LOGIN LOCAL de médico
const LOCAL_MEDICO = {
email: "fernando.pirichowski@souunit.com.br",
senha: "fernando",
nome: "Dr. Fernando Pirichowski",
id: "fernando.pirichowski@souunit.com.br",
} as const;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -29,31 +22,12 @@ const LoginMedico: React.FC = () => {
try {
console.log("[LoginMedico] Fazendo login com email:", formData.email);
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
await authService.login({
email: formData.email,
password: formData.senha,
});
if (!loginResult.success) {
console.log("[LoginMedico] Erro no login:", loginResult.error);
toast.error(loginResult.error || "Email ou senha incorretos");
setLoading(false);
return;
}
console.log("[LoginMedico] Login bem-sucedido!", loginResult.data);
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log("[LoginMedico] Token salvo:", token ? "SIM" : "NÃO");
if (!token) {
console.error("[LoginMedico] Token não foi salvo!");
toast.error("Erro ao salvar credenciais de autenticação");
setLoading(false);
return;
}
console.log("[LoginMedico] Login bem-sucedido!");
const ok = await loginComEmailSenha(formData.email, formData.senha);
@ -149,10 +123,15 @@ const LoginMedico: React.FC = () => {
{loading ? "Entrando..." : "Entrar"}
</button>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 text-center">
<strong>{LOCAL_MEDICO.email}</strong> /{" "}
<strong>{LOCAL_MEDICO.senha}</strong>
</p>
<div className="text-center mt-4">
<button
type="button"
onClick={() => navigate("/cadastro/medico")}
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm transition-colors"
>
Não tem conta? Cadastre-se aqui
</button>
</div>
</form>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { User, Mail, Lock } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService, patientService, userService } from "../services";
const LoginPaciente: React.FC = () => {
const [formData, setFormData] = useState({
@ -57,14 +58,6 @@ const LoginPaciente: React.FC = () => {
const { loginPaciente } = useAuth();
// Credenciais fixas para LOGIN LOCAL de paciente
const LOCAL_PATIENT = {
email: "guilhermesilvagomes1020@gmail.com",
senha: "guilherme123",
nome: "Guilherme Silva Gomes",
id: "guilhermesilvagomes1020@gmail.com",
} as const;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -73,61 +66,28 @@ const LoginPaciente: React.FC = () => {
console.log("[LoginPaciente] Fazendo login com email:", formData.email);
// Fazer login via API Supabase
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
await authService.login({
email: formData.email,
password: formData.senha,
});
if (!loginResult.success) {
console.log("[LoginPaciente] Erro no login:", loginResult.error);
toast.error(loginResult.error || "Email ou senha incorretos");
setLoading(false);
return;
}
console.log("[LoginPaciente] Login bem-sucedido!", loginResult.data);
// Verificar se o token foi salvo
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
const refreshToken = tokenStore.getRefreshToken();
console.log("[LoginPaciente] Token salvo:", token ? "SIM" : "NÃO");
console.log(
"[LoginPaciente] Refresh token salvo:",
refreshToken ? "SIM" : "NÃO"
);
if (!token) {
console.error(
"[LoginPaciente] Token não foi salvo! Dados do login:",
loginResult.data
);
toast.error("Erro ao salvar credenciais de autenticação");
setLoading(false);
return;
}
console.log("[LoginPaciente] Login bem-sucedido!");
// Buscar dados do paciente da API
const { listPatients } = await import("../services/pacienteService");
const pacientesResult = await listPatients({ search: formData.email });
const pacientes = await patientService.list();
const paciente = pacientes.find((p: any) => p.email === formData.email);
console.log(
"[LoginPaciente] Resultado da busca de pacientes:",
pacientesResult
);
const paciente = pacientesResult.data?.[0];
console.log("[LoginPaciente] Paciente encontrado:", paciente);
if (paciente) {
console.log("[LoginPaciente] Paciente encontrado:", {
id: paciente.id,
nome: paciente.nome,
nome: paciente.full_name,
email: paciente.email,
});
const ok = await loginPaciente({
id: paciente.id,
nome: paciente.nome,
nome: paciente.full_name,
email: paciente.email,
});
@ -154,8 +114,82 @@ const LoginPaciente: React.FC = () => {
const handleCadastro = async (e: React.FormEvent) => {
e.preventDefault();
// Redirecionar para a página de cadastro dedicada
navigate("/cadastro-paciente");
// Validações básicas
if (!cadastroData.nome.trim()) {
toast.error("Nome completo é obrigatório");
return;
}
if (!cadastroData.email.trim()) {
toast.error("Email é obrigatório");
return;
}
if (!cadastroData.cpf.trim()) {
toast.error("CPF é obrigatório");
return;
}
if (!cadastroData.telefone.trim()) {
toast.error("Telefone celular é obrigatório");
return;
}
setLoading(true);
try {
// Cadastro público usando create-user com create_patient_record
await userService.createUser({
email: cadastroData.email.trim(),
full_name: cadastroData.nome.trim(),
role: "paciente",
phone: cadastroData.telefone.trim(),
cpf: cadastroData.cpf.trim().replace(/\D/g, ""),
phone_mobile: cadastroData.telefone.trim().replace(/\D/g, ""),
create_patient_record: true,
});
toast.success(
"Cadastro realizado! Verifique seu email para ativar a conta e definir sua senha."
);
// Limpar formulário e voltar para tela de login
setCadastroData({
nome: "",
email: "",
senha: "",
confirmarSenha: "",
telefone: "",
cpf: "",
dataNascimento: "",
convenio: "",
altura: "",
peso: "",
cep: "",
logradouro: "",
bairro: "",
cidade: "",
estado: "",
});
// Preencher email no formulário de login
setFormData({
email: cadastroData.email,
senha: "",
});
setShowCadastro(false);
} catch (error: any) {
console.error("Erro ao cadastrar:", error);
const errorMessage =
error?.response?.data?.message ||
error?.message ||
"Erro ao realizar cadastro";
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
// Login LOCAL: cria uma sessão de paciente sem chamar a API
@ -169,69 +203,25 @@ const LoginPaciente: React.FC = () => {
setLoading(true);
try {
// Fazer login via API Supabase
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
await authService.login({
email: email,
password: senha,
});
if (!loginResult.success) {
console.log(
"[LoginPaciente] Login via API falhou, usando modo local sem token"
);
console.log("[LoginPaciente] Erro:", loginResult.error);
// Fallback: validar credenciais locais hardcoded
if (email !== LOCAL_PATIENT.email || senha !== LOCAL_PATIENT.senha) {
toast.error("Credenciais inválidas");
setLoading(false);
return;
}
// Login local SEM token (modo de desenvolvimento)
toast(
"⚠️ Modo local ativo: algumas funcionalidades podem não funcionar sem API",
{
icon: "⚠️",
duration: 5000,
}
);
const ok = await loginPaciente({
id: LOCAL_PATIENT.id,
nome: LOCAL_PATIENT.nome,
email: LOCAL_PATIENT.email,
});
if (ok) {
navigate("/acompanhamento");
} else {
toast.error("Não foi possível iniciar a sessão local");
}
setLoading(false);
return;
}
console.log("[LoginPaciente] Login via API bem-sucedido!");
// Verificar se o token foi salvo
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log("[LoginPaciente] Token salvo:", token ? "SIM" : "NÃO");
// Buscar dados do paciente da API
const { listPatients } = await import("../services/pacienteService");
const pacientesResult = await listPatients({ search: email });
const paciente = pacientesResult.data?.[0];
const pacientes = await patientService.list();
const paciente = pacientes.find((p: any) => p.email === email);
if (paciente) {
console.log(
"[LoginPaciente] Paciente encontrado na API:",
paciente.nome
paciente.full_name
);
const ok = await loginPaciente({
id: paciente.id,
nome: paciente.nome,
nome: paciente.full_name,
email: paciente.email,
});
@ -363,10 +353,16 @@ const LoginPaciente: React.FC = () => {
>
{loading ? "Entrando..." : "Entrar"}
</button>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 text-center">
<strong>{LOCAL_PATIENT.email}</strong> /{" "}
<strong>{LOCAL_PATIENT.senha}</strong>
</p>
<div className="text-center mt-4">
<button
type="button"
onClick={() => setShowCadastro(true)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm transition-colors"
>
Não tem conta? Cadastre-se aqui
</button>
</div>
</form>
) : (
/* Formulário de Cadastro */
@ -563,72 +559,11 @@ const LoginPaciente: React.FC = () => {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="cad_senha"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Senha
</label>
<input
id="cad_senha"
type="password"
value={cadastroData.senha}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
senha: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
minLength={6}
required
autoComplete="new-password"
/>
</div>
<div>
<label
htmlFor="cad_confirma_senha"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirmar Senha
</label>
<input
id="cad_confirma_senha"
type="password"
value={cadastroData.confirmarSenha}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
confirmarSenha: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="new-password"
aria-invalid={
cadastroData.confirmarSenha !== "" &&
cadastroData.confirmarSenha !== cadastroData.senha
}
aria-describedby={
cadastroData.confirmarSenha !== "" &&
cadastroData.confirmarSenha !== cadastroData.senha
? "cad_senha_help"
: undefined
}
/>
{cadastroData.confirmarSenha !== "" &&
cadastroData.confirmarSenha !== cadastroData.senha && (
<p
id="cad_senha_help"
className="mt-1 text-xs text-red-400"
>
As senhas não coincidem.
</p>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
Após o cadastro, você receberá um email com link para
ativar sua conta e definir sua senha.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -637,7 +572,7 @@ const LoginPaciente: React.FC = () => {
htmlFor="cad_telefone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Telefone
Telefone Celular *
</label>
<input
id="cad_telefone"

View File

@ -3,6 +3,7 @@ import { Mail, Lock, Clipboard } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService } from "../services";
const LoginSecretaria: React.FC = () => {
const [formData, setFormData] = useState({
@ -14,14 +15,6 @@ const LoginSecretaria: React.FC = () => {
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
// Credenciais fixas para LOGIN LOCAL de secretaria
const LOCAL_SECRETARIA = {
email: "secretaria.mediconnect@gmail.com",
senha: "secretaria@mediconnect",
nome: "Secretaria MediConnect",
id: "secretaria.mediconnect@gmail.com",
} as const;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -29,31 +22,12 @@ const LoginSecretaria: React.FC = () => {
try {
console.log("[LoginSecretaria] Fazendo login com email:", formData.email);
const authService = (await import("../services/authService")).default;
const loginResult = await authService.login({
await authService.login({
email: formData.email,
password: formData.senha,
});
if (!loginResult.success) {
console.log("[LoginSecretaria] Erro no login:", loginResult.error);
toast.error(loginResult.error || "Email ou senha incorretos");
setLoading(false);
return;
}
console.log("[LoginSecretaria] Login bem-sucedido!", loginResult.data);
const tokenStore = (await import("../services/tokenStore")).default;
const token = tokenStore.getAccessToken();
console.log("[LoginSecretaria] Token salvo:", token ? "SIM" : "NÃO");
if (!token) {
console.error("[LoginSecretaria] Token não foi salvo!");
toast.error("Erro ao salvar credenciais de autenticação");
setLoading(false);
return;
}
console.log("[LoginSecretaria] Login bem-sucedido!");
const ok = await loginComEmailSenha(formData.email, formData.senha);
@ -148,11 +122,6 @@ const LoginSecretaria: React.FC = () => {
>
{loading ? "Entrando..." : "Entrar"}
</button>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 text-center">
<strong>{LOCAL_SECRETARIA.email}</strong> /{" "}
<strong>{LOCAL_SECRETARIA.senha}</strong>
</p>
</form>
</div>
</div>

View File

@ -8,7 +8,6 @@ import {
Edit,
Trash2,
Shield,
X,
Search,
RefreshCw,
UserCheck,
@ -18,29 +17,21 @@ import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import {
createPatient,
listPatients,
updatePatient,
deletePatient,
type Paciente,
} from "../services/pacienteService";
import {
createDoctor,
listDoctors,
updateDoctor,
deleteDoctor,
patientService,
type Patient,
doctorService,
type Doctor,
} from "../services/doctorService";
import {
createUser,
userService,
type UserInfo,
type UserRole,
type CreateUserInput,
type RoleType,
} from "../services/adminService";
import adminUserService, {
FullUserInfo,
UpdateUserData,
UserRole,
} from "../services/adminUserService";
profileService,
} from "../services";
import type { CrmUF } from "../services/doctors/types";
// Type aliases para compatibilidade
type Paciente = Patient;
type FullUserInfo = UserInfo;
type TabType = "pacientes" | "usuarios" | "medicos";
@ -79,14 +70,18 @@ const PainelAdmin: React.FC = () => {
const [searchTerm, setSearchTerm] = useState("");
const [showUserModal, setShowUserModal] = useState(false);
const [editingUser, setEditingUser] = useState<FullUserInfo | null>(null);
const [editForm, setEditForm] = useState<UpdateUserData>({});
const [editForm, setEditForm] = useState<{
full_name?: string;
email?: string;
phone?: string;
disabled?: boolean;
}>({});
const [managingRolesUser, setManagingRolesUser] =
useState<FullUserInfo | null>(null);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [newRole, setNewRole] = useState<string>("");
const [newRole, setNewRole] = useState<UserRole>("user");
const [formUser, setFormUser] = useState<CreateUserInput>({
email: "",
password: "",
full_name: "",
phone: "",
role: "user",
@ -141,14 +136,59 @@ const PainelAdmin: React.FC = () => {
const loadUsuarios = async () => {
setLoading(true);
try {
const result = await adminUserService.listAllUsers();
if (result.success && result.data) {
setUsuarios(result.data);
} else {
toast.error(result.error || "Erro ao carregar usuários");
}
} catch {
// Lista todos os perfis (usuários) do sistema
const profiles = await profileService.list();
// Busca todas as roles de uma vez
const allRoles = await userService.listRoles();
// Converte Profile para UserInfo (estrutura compatível com a interface esperada)
const userInfoList: FullUserInfo[] = profiles.map((profile) => {
// Filtra as roles deste usuário específico
const userRoles = allRoles
.filter((role) => role.user_id === profile.id)
.map((role) => role.role);
// Calcula permissões baseado nas roles
const isAdmin = userRoles.includes("admin");
const isManager = userRoles.includes("gestor");
const isDoctor = userRoles.includes("medico");
const isSecretary = userRoles.includes("secretaria");
return {
user: {
id: profile.id || "",
email: profile.email || "",
email_confirmed_at: null,
created_at: profile.created_at || new Date().toISOString(),
last_sign_in_at: null,
},
profile: {
id: profile.id || "",
full_name: profile.full_name,
email: profile.email,
phone: profile.phone || null,
avatar_url: profile.avatar_url,
disabled: profile.disabled || false,
created_at: profile.created_at || new Date().toISOString(),
updated_at: profile.updated_at || new Date().toISOString(),
},
roles: userRoles,
permissions: {
isAdmin,
isManager,
isDoctor,
isSecretary,
isAdminOrManager: isAdmin || isManager,
},
};
});
setUsuarios(userInfoList);
} catch (error) {
console.error("Erro ao carregar usuários:", error);
toast.error("Erro ao carregar usuários");
setUsuarios([]);
} finally {
setLoading(false);
}
@ -157,10 +197,8 @@ const PainelAdmin: React.FC = () => {
const loadPacientes = async () => {
setLoading(true);
try {
const response = await listPatients({ per_page: 100 });
if ("data" in response) {
setPacientes(response.data);
}
const patients = await patientService.list();
setPacientes(patients);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
@ -172,10 +210,8 @@ const PainelAdmin: React.FC = () => {
const loadMedicos = async () => {
setLoading(true);
try {
const response = await listDoctors();
if (response.success && response.data) {
setMedicos(response.data);
}
const doctors = await doctorService.list();
setMedicos(doctors);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
@ -189,15 +225,12 @@ const PainelAdmin: React.FC = () => {
setLoading(true);
try {
const response = await createUser(formUser);
if (response.success) {
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`);
setShowUserModal(false);
resetFormUser();
} else {
toast.error(response.error || "Erro ao criar usuário");
}
// isPublicRegistration = false porque é admin criando
const newUser = await userService.createUser(formUser, false);
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`);
setShowUserModal(false);
resetFormUser();
loadUsuarios();
} catch (error) {
console.error("Erro ao criar usuário:", error);
toast.error("Erro ao criar usuário");
@ -207,79 +240,113 @@ const PainelAdmin: React.FC = () => {
};
// Funções de gerenciamento de usuários
const handleEditUser = (user: FullUserInfo) => {
setEditingUser(user);
setEditForm({
full_name: user.profile?.full_name || "",
email: user.profile?.email || "",
phone: user.profile?.phone || "",
disabled: user.profile?.disabled || false,
});
// TODO: Implement admin user endpoints (update, enable/disable, delete)
const handleEditUser = (_user: FullUserInfo) => {
toast.error("Função de edição de usuário ainda não implementada");
// setEditingUser(user);
// setEditForm({
// full_name: user.profile?.full_name || "",
// email: user.profile?.email || "",
// phone: user.profile?.phone || "",
// disabled: user.profile?.disabled || false,
// });
};
const handleSaveEditUser = async () => {
if (!editingUser) return;
try {
const result = await adminUserService.updateUser(
editingUser.user.id,
editForm
);
if (result.success) {
toast.success("Usuário atualizado com sucesso!");
setEditingUser(null);
loadUsuarios();
} else {
toast.error(result.error || "Erro ao atualizar usuário");
}
} catch {
toast.error("Erro ao atualizar usuário");
}
toast.error("Função de salvar usuário ainda não implementada");
// TODO: Implement adminUserService.updateUser endpoint
// if (!editingUser) return;
// try {
// const result = await adminUserService.updateUser(editingUser.user.id, editForm);
// if (result.success) {
// toast.success("Usuário atualizado com sucesso!");
// setEditingUser(null);
// loadUsuarios();
// }
// } catch {
// toast.error("Erro ao atualizar usuário");
// }
};
const handleToggleStatusUser = async (
userId: string,
currentStatus: boolean
_userId: string,
_currentStatus: boolean
) => {
try {
const result = currentStatus
? await adminUserService.enableUser(userId)
: await adminUserService.disableUser(userId);
toast.error(
"Função de habilitar/desabilitar usuário ainda não implementada"
);
// TODO: Implement adminUserService.enableUser/disableUser endpoints
// try {
// const result = currentStatus
// ? await adminUserService.enableUser(userId)
// : await adminUserService.disableUser(userId);
// if (result.success) {
// toast.success(`Usuário ${currentStatus ? "habilitado" : "desabilitado"} com sucesso!`);
// loadUsuarios();
// }
// } catch {
// toast.error("Erro ao alterar status do usuário");
// }
};
if (result.success) {
toast.success(
`Usuário ${
currentStatus ? "habilitado" : "desabilitado"
} com sucesso!`
);
loadUsuarios();
} else {
toast.error(result.error || "Erro ao alterar status do usuário");
const handleDeleteUser = async (_userId: string, _userName: string) => {
toast.error("Função de deletar usuário ainda não implementada");
// TODO: Implement adminUserService.deleteUser endpoint
// if (!confirm(`Tem certeza que deseja deletar o usuário "${userName}"?`)) return;
// try {
// const result = await adminUserService.deleteUser(userId);
// if (result.success) {
// toast.success("Usuário deletado com sucesso!");
// loadUsuarios();
// }
// } catch {
// toast.error("Erro ao deletar usuário");
// }
};
// Funções de gerenciamento de roles
const handleAddRole = async () => {
if (!managingRolesUser) return;
try {
await userService.addUserRole(managingRolesUser.user.id, newRole);
toast.success(`Role "${newRole}" adicionada com sucesso!`);
setNewRole("user");
await loadUsuarios();
// Atualiza o estado local do modal
const updatedUsers = usuarios.find(
(u) => u.user.id === managingRolesUser.user.id
);
if (updatedUsers) {
setManagingRolesUser(updatedUsers);
}
} catch {
toast.error("Erro ao alterar status do usuário");
} catch (error) {
console.error("Erro ao adicionar role:", error);
toast.error("Erro ao adicionar role");
}
};
const handleDeleteUser = async (userId: string, userName: string) => {
if (
!confirm(
`Tem certeza que deseja deletar o usuário "${userName}"? Esta ação não pode ser desfeita.`
)
) {
return;
}
const handleRemoveRole = async (role: UserRole) => {
if (!managingRolesUser) return;
if (!confirm(`Tem certeza que deseja remover a role "${role}"?`)) return;
try {
const result = await adminUserService.deleteUser(userId);
if (result.success) {
toast.success("Usuário deletado com sucesso!");
loadUsuarios();
} else {
toast.error(result.error || "Erro ao deletar usuário");
await userService.removeUserRole(managingRolesUser.user.id, role);
toast.success(`Role "${role}" removida com sucesso!`);
await loadUsuarios();
// Atualiza o estado local do modal
const updatedUsers = usuarios.find(
(u) => u.user.id === managingRolesUser.user.id
);
if (updatedUsers) {
setManagingRolesUser(updatedUsers);
}
} catch {
toast.error("Erro ao deletar usuário");
} catch (error) {
console.error("Erro ao remover role:", error);
toast.error("Erro ao remover role");
}
};
@ -287,23 +354,23 @@ const PainelAdmin: React.FC = () => {
const handleEditPaciente = (paciente: Paciente) => {
setEditingPaciente(paciente);
setFormPaciente({
full_name: paciente.nome,
full_name: paciente.full_name,
cpf: paciente.cpf || "",
email: paciente.email || "",
phone_mobile: paciente.telefone || "",
birth_date: paciente.dataNascimento || "",
social_name: paciente.socialName || "",
sex: paciente.sexo || "",
blood_type: paciente.tipoSanguineo || "",
weight_kg: paciente.pesoKg?.toString() || "",
height_m: paciente.alturaM?.toString() || "",
street: paciente.endereco?.rua || "",
number: paciente.endereco?.numero || "",
complement: paciente.endereco?.complemento || "",
neighborhood: paciente.endereco?.bairro || "",
city: paciente.endereco?.cidade || "",
state: paciente.endereco?.estado || "",
cep: paciente.endereco?.cep || "",
phone_mobile: paciente.phone_mobile || "",
birth_date: paciente.birth_date || "",
social_name: paciente.social_name || "",
sex: paciente.sex || "",
blood_type: paciente.blood_type || "",
weight_kg: paciente.weight_kg?.toString() || "",
height_m: paciente.height_m?.toString() || "",
street: paciente.street || "",
number: paciente.number || "",
complement: paciente.complement || "",
neighborhood: paciente.neighborhood || "",
city: paciente.city || "",
state: paciente.state || "",
cep: paciente.cep || "",
});
setShowPacienteModal(true);
};
@ -313,53 +380,58 @@ const PainelAdmin: React.FC = () => {
setLoading(true);
try {
const data = {
nome: formPaciente.full_name,
const patientData = {
full_name: formPaciente.full_name,
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
email: formPaciente.email,
telefone: formPaciente.phone_mobile,
dataNascimento: formPaciente.birth_date,
socialName: formPaciente.social_name,
sexo: formPaciente.sex,
tipoSanguineo: formPaciente.blood_type,
pesoKg: formPaciente.weight_kg
phone_mobile: formPaciente.phone_mobile,
birth_date: formPaciente.birth_date,
social_name: formPaciente.social_name,
sex: formPaciente.sex,
blood_type: formPaciente.blood_type,
weight_kg: formPaciente.weight_kg
? parseFloat(formPaciente.weight_kg)
: undefined,
alturaM: formPaciente.height_m
height_m: formPaciente.height_m
? parseFloat(formPaciente.height_m)
: undefined,
endereco: {
rua: formPaciente.street,
numero: formPaciente.number,
complemento: formPaciente.complement,
bairro: formPaciente.neighborhood,
cidade: formPaciente.city,
estado: formPaciente.state,
cep: formPaciente.cep,
},
street: formPaciente.street,
number: formPaciente.number,
complement: formPaciente.complement,
neighborhood: formPaciente.neighborhood,
city: formPaciente.city,
state: formPaciente.state,
cep: formPaciente.cep,
};
if (editingPaciente) {
const response = await updatePatient(editingPaciente.id, data);
if (response.success) {
toast.success("Paciente atualizado com sucesso!");
setShowPacienteModal(false);
setEditingPaciente(null);
resetFormPaciente();
loadPacientes();
} else {
toast.error(response.error || "Erro ao atualizar paciente");
}
await patientService.update(editingPaciente.id, patientData);
toast.success("Paciente atualizado com sucesso!");
setShowPacienteModal(false);
setEditingPaciente(null);
resetFormPaciente();
loadPacientes();
} else {
const response = await createPatient(data);
if (response.success) {
toast.success("Paciente criado com sucesso!");
setShowPacienteModal(false);
resetFormPaciente();
loadPacientes();
} else {
toast.error(response.error || "Erro ao criar paciente");
}
// Usar create-user com create_patient_record=true (nova API 21/10)
// isPublicRegistration = false porque é admin criando
await userService.createUser(
{
email: patientData.email,
full_name: patientData.full_name,
phone: patientData.phone_mobile,
role: "paciente",
create_patient_record: true,
cpf: patientData.cpf,
phone_mobile: patientData.phone_mobile,
},
false
);
toast.success(
"Paciente criado com sucesso! Magic link enviado para o email."
);
setShowPacienteModal(false);
resetFormPaciente();
loadPacientes();
}
} catch (error) {
console.error("Erro ao salvar paciente:", error);
@ -380,18 +452,10 @@ const PainelAdmin: React.FC = () => {
try {
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
const response = await deletePatient(id);
console.log("[PainelAdmin] Resultado da deleção:", response);
if (response.success) {
toast.success("Paciente deletado com sucesso!");
loadPacientes();
} else {
console.error("[PainelAdmin] Falha ao deletar:", response.error);
toast.error(response.error || "Erro ao deletar paciente");
}
await patientService.delete(id);
console.log("[PainelAdmin] Paciente deletado com sucesso");
toast.success("Paciente deletado com sucesso!");
loadPacientes();
} catch (error) {
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
toast.error("Erro ao deletar paciente");
@ -436,26 +500,42 @@ const PainelAdmin: React.FC = () => {
};
if (editingMedico) {
const response = await updateDoctor(editingMedico.id!, medicoData);
if (response.success) {
toast.success("Médico atualizado com sucesso!");
setShowMedicoModal(false);
setEditingMedico(null);
resetFormMedico();
loadMedicos();
} else {
toast.error(response.error || "Erro ao atualizar médico");
}
await doctorService.update(editingMedico.id!, medicoData);
toast.success("Médico atualizado com sucesso!");
setShowMedicoModal(false);
setEditingMedico(null);
resetFormMedico();
loadMedicos();
} else {
const response = await createDoctor(medicoData);
if (response.success) {
toast.success("Médico criado com sucesso!");
setShowMedicoModal(false);
resetFormMedico();
loadMedicos();
} else {
toast.error(response.error || "Erro ao criar médico");
}
// Usar create-user com role=medico (nova API 21/10 - create-doctor não cria auth user)
// isPublicRegistration = false porque é admin criando
await userService.createUser(
{
email: medicoData.email,
full_name: medicoData.full_name,
phone: medicoData.phone_mobile,
role: "medico",
},
false
);
// Depois criar registro na tabela doctors com createDoctor (sem password)
await userService.createDoctor({
crm: medicoData.crm,
crm_uf: medicoData.crm_uf,
cpf: medicoData.cpf,
full_name: medicoData.full_name,
email: medicoData.email,
specialty: medicoData.specialty,
phone_mobile: medicoData.phone_mobile,
});
toast.success(
"Médico criado com sucesso! Magic link enviado para o email."
);
setShowMedicoModal(false);
resetFormMedico();
loadMedicos();
}
} catch (error) {
console.error("Erro ao salvar médico:", error);
@ -475,13 +555,9 @@ const PainelAdmin: React.FC = () => {
}
try {
const response = await deleteDoctor(id);
if (response.success) {
toast.success("Médico deletado com sucesso!");
loadMedicos();
} else {
toast.error(response.error || "Erro ao deletar médico");
}
await doctorService.delete(id);
toast.success("Médico deletado com sucesso!");
loadMedicos();
} catch {
toast.error("Erro ao deletar médico");
}
@ -512,7 +588,6 @@ const PainelAdmin: React.FC = () => {
const resetFormUser = () => {
setFormUser({
email: "",
password: "",
full_name: "",
phone: "",
role: "user",
@ -572,12 +647,13 @@ const PainelAdmin: React.FC = () => {
"TO",
];
const availableRoles: RoleType[] = [
const availableRoles: UserRole[] = [
"admin",
"gestor",
"medico",
"secretaria",
"user",
"paciente",
];
return (
@ -688,12 +764,14 @@ const PainelAdmin: React.FC = () => {
} hover:bg-gray-100`}
>
<div>
<h3 className="font-semibold text-lg">{p.nome}</h3>
<h3 className="font-semibold text-lg">
{p.full_name}
</h3>
<p className="text-gray-600 text-sm">
{p.email} | {p.telefone}
{p.email} | {p.phone_mobile}
</p>
<p className="text-gray-500 text-xs">
CPF: {p.cpf} | Nascimento: {p.dataNascimento}
CPF: {p.cpf} | Nascimento: {p.birth_date}
</p>
</div>
<div className="flex gap-2">
@ -705,7 +783,9 @@ const PainelAdmin: React.FC = () => {
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeletePaciente(p.id, p.nome)}
onClick={() =>
p.id && handleDeletePaciente(p.id, p.full_name)
}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
title="Deletar"
>
@ -1144,6 +1224,15 @@ const PainelAdmin: React.FC = () => {
placeholder="(00) 00000-0000"
/>
</div>
{!editingPaciente && (
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
🔐 <strong>Ativação de Conta:</strong> Um link mágico
(magic link) será enviado automaticamente para o email
do paciente para ativar a conta e definir senha.
</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">
Data de Nascimento
@ -1273,21 +1362,6 @@ const PainelAdmin: React.FC = () => {
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Senha Temporária * (mínimo 6 caracteres)
</label>
<input
type="password"
required
minLength={6}
value={formUser.password}
onChange={(e) =>
setFormUser({ ...formUser, password: e.target.value })
}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Telefone
@ -1312,7 +1386,7 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setFormUser({
...formUser,
role: e.target.value as RoleType,
role: e.target.value as UserRole,
})
}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
@ -1490,6 +1564,15 @@ const PainelAdmin: React.FC = () => {
placeholder="(00) 00000-0000"
/>
</div>
{!editingMedico && (
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
🔐 <strong>Ativação de Conta:</strong> Um link mágico
(magic link) será enviado automaticamente para o email
do médico para ativar a conta e definir senha.
</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">
Data de Nascimento
@ -1661,45 +1744,31 @@ const PainelAdmin: React.FC = () => {
Roles Atuais:
</h3>
<div className="flex flex-wrap gap-2">
{userRoles.length > 0 ? (
userRoles.map((userRole) => (
{managingRolesUser &&
managingRolesUser.roles &&
managingRolesUser.roles.length > 0 ? (
managingRolesUser.roles.map((role, index) => (
<div
key={userRole.id}
key={`${role}-${index}`}
className={`flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold ${
userRole.role === "admin"
role === "admin"
? "bg-purple-100 text-purple-700"
: userRole.role === "gestor"
: role === "gestor"
? "bg-blue-100 text-blue-700"
: userRole.role === "medico"
: role === "medico"
? "bg-indigo-100 text-indigo-700"
: userRole.role === "secretaria"
: role === "secretaria"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-700"
}`}
>
{userRole.role}
{role}
<button
onClick={async () => {
const result = await adminUserService.removeUserRole(
userRole.id
);
if (result.success) {
toast.success("Role removido com sucesso!");
const rolesResult =
await adminUserService.getUserRoles(
managingRolesUser.user.id
);
if (rolesResult.success && rolesResult.data) {
setUserRoles(rolesResult.data);
}
loadUsuarios();
} else {
toast.error(result.error || "Erro ao remover role");
}
}}
onClick={() => handleRemoveRole(role)}
className="hover:bg-black hover:bg-opacity-10 rounded-full p-0.5"
title="Remover role"
>
<X className="w-3 h-3" />
<span className="sr-only">Remover role {role}</span>
</button>
</div>
))
@ -1719,49 +1788,19 @@ const PainelAdmin: React.FC = () => {
<div className="flex gap-2">
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40 text-sm"
onChange={(e) => setNewRole(e.target.value as UserRole)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Selecione um role...</option>
<option value="admin">Admin</option>
<option value="gestor">Gestor</option>
<option value="user">User</option>
<option value="paciente">Paciente</option>
<option value="secretaria">Secretaria</option>
<option value="medico">Médico</option>
<option value="secretaria">Secretária</option>
<option value="user">Usuário</option>
<option value="gestor">Gestor</option>
<option value="admin">Admin</option>
</select>
<button
onClick={async () => {
if (!newRole) {
toast.error("Selecione um role");
return;
}
const result = await adminUserService.addUserRole(
managingRolesUser.user.id,
newRole as
| "admin"
| "gestor"
| "medico"
| "secretaria"
| "user"
);
if (result.success) {
toast.success("Role adicionado com sucesso!");
setNewRole("");
const rolesResult = await adminUserService.getUserRoles(
managingRolesUser.user.id
);
if (rolesResult.success && rolesResult.data) {
setUserRoles(rolesResult.data);
}
loadUsuarios();
} else {
toast.error(result.error || "Erro ao adicionar role");
}
}}
disabled={!newRole}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2"
onClick={handleAddRole}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Adicionar
@ -1773,8 +1812,7 @@ const PainelAdmin: React.FC = () => {
<button
onClick={() => {
setManagingRolesUser(null);
setUserRoles([]);
setNewRole("");
setNewRole("user");
}}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2"
>

View File

@ -17,20 +17,29 @@ import {
Plus,
Edit,
Trash2,
Pencil,
User,
} from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useNavigate } from "react-router-dom";
import consultasService, { Consulta as ServiceConsulta } from "../services/consultasService";
import { listPatients } from "../services/pacienteService";
import {
appointmentService,
patientService,
reportService,
type Appointment,
type Patient,
type CreateReportInput,
} from "../services";
import type { Report } from "../services/reports/types";
import { useAuth } from "../hooks/useAuth";
import relatorioService, {
RelatorioCreate,
} from "../services/relatorioService";
import DisponibilidadeMedico from "../components/DisponibilidadeMedico";
import ConsultaModal from "../components/consultas/ConsultaModal";
import { AvatarUpload } from "../components/ui/AvatarUpload";
// Type aliases para compatibilidade
type ServiceConsulta = Appointment;
type RelatorioCreate = CreateReportInput;
interface ConsultaUI {
id: string;
@ -44,8 +53,6 @@ interface ConsultaUI {
observacoes?: string;
}
const PainelMedico: React.FC = () => {
const { user, roles, logout } = useAuth();
const navigate = useNavigate();
@ -58,70 +65,7 @@ const PainelMedico: React.FC = () => {
roles.includes("admin"));
const medicoId = temAcessoMedico ? user.id : "";
const medicoNome = user?.nome || "Médico";
const [avatarUrl, setAvatarUrl] = useState<string | null>(user?.avatar_url || null);
const [avatarEditMode, setAvatarEditMode] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
// Função para buscar avatar público
const fetchAvatarUrl = useCallback(() => {
if (!user?.id) return;
// Tenta jpg, png, webp
const base = `https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/public/avatars/${user.id}/avatar`;
const tryExts = async () => {
for (const ext of ["jpg", "png", "webp"]) {
const url = `${base}.${ext}`;
try {
const res = await fetch(url, { method: "HEAD" });
if (res.ok) {
setAvatarUrl(url);
return;
}
} catch {}
}
setAvatarUrl(null);
};
tryExts();
}, [user?.id]);
useEffect(() => {
fetchAvatarUrl();
}, [fetchAvatarUrl]);
// Upload avatar
const handleAvatarUpload = async () => {
if (!avatarFile || !user?.id) return;
const formData = new FormData();
formData.append("file", avatarFile);
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/avatars/${user.id}/avatar`, {
method: "POST",
body: formData,
});
// Atualiza avatar_url no perfil
const ext = avatarFile.name.split(".").pop();
const publicUrl = `https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/public/avatars/${user.id}/avatar.${ext}`;
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/profiles?id=eq.${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: publicUrl }),
});
setAvatarEditMode(false);
setAvatarFile(null);
setAvatarUrl(publicUrl);
};
// Remover avatar
const handleAvatarRemove = async () => {
if (!user?.id) return;
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/avatars/${user.id}/avatar`, {
method: "DELETE",
});
await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/profiles?id=eq.${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: null }),
});
setAvatarUrl(null);
};
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
// State
const [activeTab, setActiveTab] = useState("dashboard");
@ -132,6 +76,8 @@ const PainelMedico: React.FC = () => {
const [editing, setEditing] = useState<ConsultaUI | null>(null);
const [relatorioModalOpen, setRelatorioModalOpen] = useState(false);
const [loadingRelatorio, setLoadingRelatorio] = useState(false);
const [laudos, setLaudos] = useState<Report[]>([]);
const [loadingLaudos, setLoadingLaudos] = useState(false);
const [pacientesDisponiveis, setPacientesDisponiveis] = useState<
Array<{ id: string; nome: string }>
>([]);
@ -157,39 +103,38 @@ const PainelMedico: React.FC = () => {
const fetchConsultas = useCallback(async () => {
setLoading(true);
try {
let resp;
let appointments;
if (user?.role === "admin" || roles.includes("admin")) {
// Admin: busca todas as consultas do sistema
resp = await consultasService.listarTodas();
appointments = await appointmentService.list();
} else {
// Médico comum: busca todas as consultas do próprio médico
if (!medicoId) return;
resp = await consultasService.listarPorMedico(medicoId);
appointments = await appointmentService.list({ doctor_id: medicoId });
}
if (resp && resp.success && resp.data) {
// Buscar nomes dos pacientes usando getPatientById
const { getPatientById } = await import("../services/pacienteService");
if (appointments && appointments.length > 0) {
// Buscar nomes dos pacientes
const consultasComNomes = await Promise.all(
resp.data.map(async (c) => {
appointments.map(async (appt: Appointment) => {
let pacienteNome = "Paciente Desconhecido";
try {
const pacienteResp = await getPatientById(c.pacienteId);
if (pacienteResp.success && pacienteResp.data) {
pacienteNome = pacienteResp.data.nome;
const patient = await patientService.getById(appt.patient_id);
if (patient) {
pacienteNome = patient.full_name;
}
} catch (error) {
console.error("Erro ao buscar nome do paciente:", error);
}
return {
id: c.id,
pacienteId: c.pacienteId,
medicoId: c.medicoId,
id: appt.id,
pacienteId: appt.patient_id,
medicoId: appt.doctor_id,
pacienteNome,
medicoNome: medicoNome,
dataHora: c.dataHora,
status: c.status,
tipo: c.tipo,
observacoes: c.observacoes,
dataHora: appt.scheduled_at,
status: appt.status,
tipo: appt.appointment_type,
observacoes: appt.notes || undefined,
};
})
);
@ -206,20 +151,46 @@ const PainelMedico: React.FC = () => {
}
}, [user, roles, medicoId, medicoNome]);
const fetchLaudos = useCallback(async () => {
if (!medicoId) return;
setLoadingLaudos(true);
try {
// Buscar todos os laudos e filtrar pelo médico criador
const allReports = await reportService.list();
// Filtrar apenas laudos criados por este médico (created_by = medicoId)
const meusLaudos = allReports.filter(
(report: Report) => report.created_by === medicoId
);
setLaudos(meusLaudos);
} catch (error) {
console.error("Erro ao buscar laudos:", error);
toast.error("Erro ao carregar laudos");
setLaudos([]);
} finally {
setLoadingLaudos(false);
}
}, [medicoId]);
useEffect(() => {
fetchConsultas();
}, [fetchConsultas]);
useEffect(() => {
if (activeTab === "reports") {
fetchLaudos();
}
}, [activeTab, fetchLaudos]);
useEffect(() => {
if (relatorioModalOpen && user?.id) {
const carregarPacientes = async () => {
try {
const response = await listPatients({ per_page: 200 });
if ("data" in response) {
const patients = await patientService.list();
if (patients && patients.length > 0) {
setPacientesDisponiveis(
response.data.map((p) => ({
id: p.id,
nome: p.nome,
patients.map((p: Patient) => ({
id: p.id || "",
nome: p.full_name,
}))
);
}
@ -246,20 +217,19 @@ const PainelMedico: React.FC = () => {
try {
const payload: RelatorioCreate = {
patient_id: formRelatorio.patient_id,
order_number: formRelatorio.order_number || "",
exam: formRelatorio.exam,
diagnosis: formRelatorio.diagnosis || "",
conclusion: formRelatorio.conclusion || "",
cid_code: formRelatorio.cid_code || "",
content_html: formRelatorio.content_html || "",
diagnosis: formRelatorio.diagnosis || undefined,
conclusion: formRelatorio.conclusion || undefined,
cid_code: formRelatorio.cid_code || undefined,
content_html: formRelatorio.content_html || undefined,
status: formRelatorio.status,
requested_by: formRelatorio.requested_by || medicoNome,
due_at: formRelatorio.due_at || "",
due_at: formRelatorio.due_at || undefined,
hide_date: formRelatorio.hide_date,
hide_signature: formRelatorio.hide_signature,
};
const resp = await relatorioService.criarRelatorio(payload);
if (resp.success) {
const newReport = await reportService.create(payload);
if (newReport) {
toast.success("Relatório criado com sucesso!");
setRelatorioModalOpen(false);
setFormRelatorio({
@ -277,7 +247,7 @@ const PainelMedico: React.FC = () => {
hide_signature: false,
});
} else {
toast.error(resp.error || "Erro ao criar relatório");
toast.error("Erro ao criar relatório");
}
} catch (error) {
console.error("Erro ao criar relatório:", error);
@ -394,6 +364,13 @@ const PainelMedico: React.FC = () => {
{ id: "appointments", label: "Consultas", icon: Clock },
{ id: "availability", label: "Disponibilidade", icon: Calendar },
{ id: "reports", label: "Relatórios", icon: FileText },
{
id: "profile",
label: "Meu Perfil",
icon: User,
isLink: true,
path: "/perfil-medico",
},
{ id: "help", label: "Ajuda", icon: HelpCircle },
{ id: "settings", label: "Configurações", icon: Settings },
];
@ -403,54 +380,15 @@ const PainelMedico: React.FC = () => {
{/* Doctor Profile */}
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="relative group">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="h-14 w-14 rounded-full object-cover border shadow"
/>
) : (
<div className="h-14 w-14 rounded-full bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-lg shadow">
{medicoNome
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
)}
<button
type="button"
className="absolute bottom-0 right-0 bg-white rounded-full p-1 border shadow group-hover:bg-indigo-100 transition"
title="Editar avatar"
onClick={() => setAvatarEditMode(true)}
style={{ lineHeight: 0 }}
>
<Pencil size={16} className="text-indigo-600" />
</button>
{avatarEditMode && (
<form
className="absolute top-0 left-16 bg-white p-2 rounded shadow z-10 flex flex-col items-center"
onSubmit={e => {
e.preventDefault();
handleAvatarUpload();
}}
>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={e => setAvatarFile(e.target.files?.[0] || null)}
className="mb-2"
/>
<button type="submit" className="text-xs bg-indigo-600 text-white px-2 py-1 rounded">Salvar</button>
<button type="button" className="text-xs ml-2" onClick={() => setAvatarEditMode(false)}>Cancelar</button>
{avatarUrl && (
<button type="button" className="text-xs text-red-600 underline mt-2" onClick={handleAvatarRemove}>Remover</button>
)}
</form>
)}
</div>
<AvatarUpload
userId={user?.id}
currentAvatarUrl={avatarUrl}
name={medicoNome}
color="green"
size="lg"
editable={true}
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{medicoNome}
@ -470,7 +408,9 @@ const PainelMedico: React.FC = () => {
<button
key={item.id}
onClick={() => {
if (item.id === "help") {
if (item.isLink && item.path) {
navigate(item.path);
} else if (item.id === "help") {
navigate("/ajuda");
} else {
setActiveTab(item.id);
@ -845,22 +785,96 @@ const PainelMedico: React.FC = () => {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Relatórios
Meus Laudos
</h1>
<button
onClick={() => setRelatorioModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
<Plus className="h-4 w-4" />
Novo Relatório
Novo Laudo
</button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Funcionalidade em desenvolvimento
</p>
</div>
{loadingLaudos ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Carregando laudos...
</p>
</div>
) : laudos.length === 0 ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Você ainda não criou nenhum laudo.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-slate-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Número
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Exame
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Diagnóstico
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Data
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
{laudos.map((laudo) => (
<tr
key={laudo.id}
className="hover:bg-gray-50 dark:hover:bg-slate-800"
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{laudo.order_number}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.exam || "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.diagnosis || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
laudo.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: laudo.status === "pending"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: laudo.status === "cancelled"
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}
>
{laudo.status === "completed"
? "Concluído"
: laudo.status === "pending"
? "Pendente"
: laudo.status === "cancelled"
? "Cancelado"
: "Rascunho"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);

View File

@ -1,759 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Calendar,
Clock,
CheckCircle,
AlertCircle,
FileText,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { useNavigate } from "react-router-dom";
import ConsultationList from "../components/consultas/ConsultationList";
import ConsultaModal from "../components/consultas/ConsultaModal";
import AvailabilityManager from "../components/agenda/AvailabilityManager";
import ExceptionsManager from "../components/agenda/ExceptionsManager";
import consultasService, {
Consulta as ServiceConsulta,
} from "../services/consultasService";
import { listPatients } from "../services/pacienteService";
import { useAuth } from "../hooks/useAuth";
import relatorioService, {
RelatorioCreate,
} from "../services/relatorioService";
interface ConsultaUI {
id: string;
pacienteId: string;
medicoId: string;
pacienteNome: string;
medicoNome: string;
dataHora: string;
status: string;
tipo?: string;
observacoes?: string;
}
interface Paciente {
_id: string;
nome: string;
telefone: string;
email: string;
convenio: string;
observacoes: string;
}
// Tipo Medico original removido (não necessário após auth)
// Antigos tipos Lumi removidos (não usados nesta refatoração)
const PainelMedico: React.FC = () => {
const { user, roles } = useAuth();
// Permite acesso se for médico ou admin
const temAcessoMedico =
user &&
(user.role === "medico" ||
roles.includes("medico") ||
roles.includes("admin"));
const medicoId = temAcessoMedico ? user.id : "";
const medicoNome = user?.nome || "Médico";
const [consultas, setConsultas] = useState<ConsultaUI[]>([]);
// pacientes detalhados não utilizados nesta versão simplificada
const [filtroData, setFiltroData] = useState("hoje");
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ConsultaUI | null>(null);
const [relatorioModalOpen, setRelatorioModalOpen] = useState(false);
const [loadingRelatorio, setLoadingRelatorio] = useState(false);
const [pacientesDisponiveis, setPacientesDisponiveis] = useState<
Array<{ id: string; nome: string }>
>([]);
const [formRelatorio, setFormRelatorio] = useState({
patient_id: "",
order_number: "",
exam: "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: "",
status: "draft" as "draft" | "pending" | "completed" | "cancelled",
requested_by: medicoNome,
due_at: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
hide_date: false,
hide_signature: false,
});
const navigate = useNavigate();
useEffect(() => {
if (!medicoId) navigate("/login-medico");
}, [medicoId, navigate]);
const fetchConsultas = useCallback(async () => {
if (!medicoId) return;
setLoading(true);
try {
const raw = localStorage.getItem("consultas_local");
let lista: ServiceConsulta[] = [];
if (raw) {
try {
lista = JSON.parse(raw);
} catch {
lista = [];
}
}
let filtradas = lista.filter((c) => c.medicoId === medicoId);
const hoje = new Date();
if (filtroData === "hoje") {
const dStr = format(hoje, "yyyy-MM-dd");
filtradas = filtradas.filter((c) => c.dataHora.startsWith(dStr));
} else if (filtroData === "amanha") {
const amanha = new Date(hoje);
amanha.setDate(hoje.getDate() + 1);
const dStr = format(amanha, "yyyy-MM-dd");
filtradas = filtradas.filter((c) => c.dataHora.startsWith(dStr));
} else if (filtroData === "semana") {
const start = new Date(hoje);
start.setDate(hoje.getDate() - hoje.getDay());
const end = new Date(start);
end.setDate(start.getDate() + 6);
filtradas = filtradas.filter((c) => {
const d = new Date(c.dataHora);
return d >= start && d <= end;
});
}
const pacientesResponse = await listPatients({ per_page: 200 }).catch(
() => ({ data: [], total: 0, page: 1, per_page: 0 })
);
const pacMap: Record<string, Paciente> = {};
const pacientesLista =
"data" in pacientesResponse ? pacientesResponse.data : [];
pacientesLista.forEach((p) => {
pacMap[p.id] = {
_id: p.id,
nome: p.nome,
telefone: p.telefone || "",
email: p.email || "",
convenio: p.convenio || "",
observacoes: p.observacoes || "",
};
});
setConsultas(
filtradas.map((c) => ({
id: c.id,
pacienteId: c.pacienteId,
medicoId: c.medicoId,
pacienteNome: pacMap[c.pacienteId]?.nome || c.pacienteId,
medicoNome: medicoNome,
dataHora: c.dataHora,
status: c.status,
tipo: c.tipo,
observacoes: c.observacoes,
}))
);
} finally {
setLoading(false);
}
}, [medicoId, filtroData, medicoNome]);
useEffect(() => {
fetchConsultas();
}, [fetchConsultas]);
// Carregar pacientes quando o modal de relatório abrir
useEffect(() => {
if (relatorioModalOpen && user?.id) {
const carregarPacientes = async () => {
try {
// Temporariamente buscando todos os pacientes para demonstração
const response = await listPatients({
per_page: 200,
// Filtro por médico removido temporariamente
});
if ("data" in response) {
setPacientesDisponiveis(
response.data.map((p) => ({
id: p.id,
nome: p.nome,
}))
);
if (response.data.length === 0) {
toast("Nenhum paciente encontrado no sistema", {
icon: "",
});
} else {
console.log(
`${response.data.length} pacientes atribuídos carregados`
);
}
}
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar lista de pacientes");
}
};
carregarPacientes();
}
}, [relatorioModalOpen, user]);
// Removido: listagem de todos os médicos; painel bloqueado ao médico logado
// fetchConsultas substitui bloco anterior
const atualizarStatusConsulta = async (id: string, status: string) => {
try {
const resp = await consultasService.atualizar(id, { status });
if (resp.success && resp.data) {
setConsultas((prev) =>
prev.map((c) =>
c.id === id ? { ...c, status: resp.data!.status } : c
)
);
// persist back
try {
const raw = localStorage.getItem("consultas_local");
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
const upd = arr.map((x: Record<string, unknown>) =>
x && x.id === id ? { ...x, status: resp.data!.status } : x
);
localStorage.setItem("consultas_local", JSON.stringify(upd));
}
}
} catch {
/* ignore */
}
toast.success("Status atualizado");
} else toast.error(resp.error || "Falha ao atualizar");
} catch {
toast.error("Erro ao atualizar status");
}
};
const handleSubmitRelatorio = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRelatorio.patient_id) {
toast.error("Selecione um paciente");
return;
}
setLoadingRelatorio(true);
try {
// Gerar número do relatório automaticamente
const orderNumber = `REL-${format(new Date(), "yyyy-MM")}-${Math.random()
.toString(36)
.substr(2, 6)
.toUpperCase()}`;
const relatorioData: RelatorioCreate = {
patient_id: formRelatorio.patient_id,
order_number: formRelatorio.order_number || orderNumber,
exam: formRelatorio.exam,
diagnosis: formRelatorio.diagnosis,
conclusion: formRelatorio.conclusion,
cid_code: formRelatorio.cid_code || undefined,
content_html:
formRelatorio.content_html ||
`<div>
<h2>${formRelatorio.exam}</h2>
<h3>Diagnóstico:</h3>
<p>${formRelatorio.diagnosis}</p>
<h3>Conclusão:</h3>
<p>${formRelatorio.conclusion}</p>
</div>`,
status: formRelatorio.status,
requested_by: formRelatorio.requested_by || medicoNome,
due_at: formRelatorio.due_at
? new Date(formRelatorio.due_at).toISOString()
: undefined,
hide_date: formRelatorio.hide_date,
hide_signature: formRelatorio.hide_signature,
};
const response = await relatorioService.criarRelatorio(relatorioData);
if (response.success) {
toast.success("Relatório criado com sucesso!");
setRelatorioModalOpen(false);
// Reset form
setFormRelatorio({
patient_id: "",
order_number: "",
exam: "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: "",
status: "draft",
requested_by: medicoNome,
due_at: format(new Date(), "yyyy-MM-dd'T'HH:mm"),
hide_date: false,
hide_signature: false,
});
} else {
toast.error(response.error || "Erro ao criar relatório");
}
} catch (error) {
console.error("Erro ao criar relatório:", error);
toast.error("Erro ao criar relatório");
} finally {
setLoadingRelatorio(false);
}
};
// salvarConsulta substituído por onSaved direto no modal
return (
<div className="space-y-6">
{/* Header com Gradiente */}
<div className="bg-gradient-to-r from-blue-700 via-blue-600 to-blue-500 dark:from-blue-800 dark:via-blue-700 dark:to-blue-600 rounded-xl shadow-lg p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<div className="text-white">
<h1 className="text-4xl font-bold mb-2">Painel do Médico</h1>
<p className="text-blue-100 text-lg">
Bem-vindo, Dr(a). {medicoNome}
</p>
<p className="text-blue-200 text-sm mt-1">
Gerencie suas consultas e agenda
</p>
</div>
<div className="flex flex-col md:flex-row gap-4 mt-6 md:mt-0">
<button
onClick={() => setRelatorioModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-md hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2"
>
<FileText className="w-5 h-5" />
<span>Criar Relatório</span>
</button>
<button
onClick={() => setModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white hover:bg-blue-50 text-blue-700 px-6 py-3 rounded-lg font-medium transition-all duration-200 shadow-md hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<Calendar className="w-5 h-5" />
<span>Nova Consulta</span>
</button>
</div>
</div>
</div>
{/* Cards de Estatísticas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Total de Consultas
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{consultas.length}
</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
<Calendar className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Confirmadas
</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
{consultas.filter((c) => c.status === "confirmada").length}
</p>
</div>
<div className="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Pendentes
</p>
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400 mt-2">
{consultas.filter((c) => c.status === "agendada").length}
</p>
</div>
<div className="bg-yellow-100 dark:bg-yellow-900/30 p-3 rounded-lg">
<Clock className="w-8 h-8 text-yellow-600 dark:text-yellow-400" />
</div>
</div>
</div>
</div>
{/* Filtros e Ações */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Suas Consultas
</h2>
<div className="flex flex-col md:flex-row gap-4 w-full md:w-auto">
<select
value={filtroData}
onChange={(e) => setFiltroData(e.target.value)}
className="form-input min-w-[200px] focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
>
<option value="hoje">Hoje</option>
<option value="amanha">Amanhã</option>
<option value="semana">Esta Semana</option>
<option value="todas">Todas</option>
</select>
</div>
</div>
</div>
{/* Gestão de Agenda do Médico */}
{temAcessoMedico && medicoId && (
<div className="space-y-6">
<AvailabilityManager doctorId={medicoId} />
<ExceptionsManager doctorId={medicoId} />
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Lista de Consultas
</h3>
</div>
<ConsultationList
itens={consultas.map((c) => ({
id: c.id,
dataHora: c.dataHora,
pacienteNome: c.pacienteNome,
medicoNome: c.medicoNome,
status: c.status,
tipo: c.tipo,
observacoes: c.observacoes,
}))}
loading={false}
showPaciente
showMedico={false}
allowDelete={false}
onChangeStatus={(id, st) => atualizarStatusConsulta(id, st)}
onEdit={(id) => {
const found = consultas.find((c) => c.id === id) || null;
setEditing(found);
setModalOpen(true);
}}
/>
{consultas.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">
<AlertCircle className="w-6 h-6 mx-auto mb-2 text-gray-400" />
Nenhuma consulta encontrada para o período.
</div>
)}
</div>
)}
<ConsultaModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
editing={
editing
? ({
id: editing.id,
pacienteId: editing.pacienteId,
medicoId: editing.medicoId,
dataHora: editing.dataHora,
status: editing.status,
tipo: editing.tipo,
} as {
id: string;
pacienteId: string;
medicoId: string;
dataHora: string;
status: string;
tipo?: string;
})
: null
}
onSaved={() => {
setModalOpen(false);
fetchConsultas();
}}
defaultMedicoId={medicoId}
lockMedico
/>
{/* Modal de Novo Relatório */}
{relatorioModalOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="novo-relatorio-title"
>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto outline-none focus:outline-none">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2
id="novo-relatorio-title"
className="text-2xl font-bold text-gray-900"
>
Novo Relatório
</h2>
<button
onClick={() => setRelatorioModalOpen(false)}
aria-label="Fechar modal de novo relatório"
className="text-gray-400 hover:text-gray-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300 focus-visible:ring-offset-2 rounded"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmitRelatorio} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Paciente *
</label>
<select
value={formRelatorio.patient_id}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
patient_id: e.target.value,
})
}
className="form-input w-full"
required
>
<option value="">Selecione um paciente</option>
{pacientesDisponiveis.map((p) => (
<option key={p.id} value={p.id}>
{p.nome}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Número do Pedido
</label>
<input
type="text"
value={formRelatorio.order_number}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
order_number: e.target.value,
})
}
className="form-input w-full"
placeholder="Será gerado automaticamente"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Status *
</label>
<select
value={formRelatorio.status}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
status: e.target.value as
| "draft"
| "pending"
| "completed"
| "cancelled",
})
}
className="form-input w-full"
required
>
<option value="draft">Rascunho</option>
<option value="pending">Pendente</option>
<option value="completed">Concluído</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Exame/Procedimento *
</label>
<input
type="text"
value={formRelatorio.exam}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
exam: e.target.value,
})
}
className="form-input w-full"
placeholder="Ex: Radiografia de Tórax, Ultrassom Abdominal"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Diagnóstico *
</label>
<textarea
value={formRelatorio.diagnosis}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
diagnosis: e.target.value,
})
}
className="form-input w-full"
rows={3}
placeholder="Descreva o diagnóstico"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Conclusão *
</label>
<textarea
value={formRelatorio.conclusion}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
conclusion: e.target.value,
})
}
className="form-input w-full"
rows={3}
placeholder="Conclusão do exame/relatório"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Código CID
</label>
<input
type="text"
value={formRelatorio.cid_code}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
cid_code: e.target.value,
})
}
className="form-input w-full"
placeholder="Ex: Z01.7, J00.0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data de Vencimento
</label>
<input
type="datetime-local"
value={formRelatorio.due_at}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
due_at: e.target.value,
})
}
className="form-input w-full"
/>
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formRelatorio.hide_date}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
hide_date: e.target.checked,
})
}
className="form-checkbox"
/>
<span className="text-sm text-gray-700">
Ocultar data no relatório
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formRelatorio.hide_signature}
onChange={(e) =>
setFormRelatorio({
...formRelatorio,
hide_signature: e.target.checked,
})
}
className="form-checkbox"
/>
<span className="text-sm text-gray-700">
Ocultar assinatura
</span>
</label>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Solicitado por:</strong> {medicoNome}
</p>
<p className="text-xs text-blue-600 mt-1">
Este relatório será associado ao médico logado
</p>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setRelatorioModalOpen(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
disabled={loadingRelatorio}
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={loadingRelatorio}
>
{loadingRelatorio ? "Gerando..." : "Gerar Relatório"}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Observações agora integradas ao fluxo de edição no modal */}
</div>
);
};
export default PainelMedico;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,517 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import toast from "react-hot-toast";
import { useAuth } from "../hooks/useAuth";
import { doctorService } from "../services";
import { AvatarUpload } from "../components/ui/AvatarUpload";
export default function PerfilMedico() {
const { user } = useAuth();
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<
"personal" | "professional" | "security"
>("personal");
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState({
full_name: "",
email: "",
phone: "",
cpf: "",
birth_date: "",
gender: "",
specialty: "",
crm: "",
crm_state: "",
bio: "",
education: "",
experience_years: "",
});
const [passwordData, setPasswordData] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
useEffect(() => {
if (user?.id) {
loadDoctorData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const loadDoctorData = async () => {
if (!user?.id) return;
try {
setLoading(true);
const doctor = await doctorService.getById(user.id);
if (doctor) {
setFormData({
full_name: doctor.full_name || "",
email: doctor.email || "",
phone: doctor.phone_mobile || "",
cpf: doctor.cpf || "",
birth_date: doctor.birth_date || "",
gender: "", // Doctor type não tem gender
specialty: doctor.specialty || "",
crm: doctor.crm || "",
crm_state: doctor.crm_uf || "",
bio: "", // Doctor type não tem bio
education: "", // Doctor type não tem education
experience_years: "", // Doctor type não tem experience_years
});
// Doctor type não tem avatar_url ainda
setAvatarUrl(undefined);
}
} catch (error) {
console.error("Erro ao carregar dados do médico:", error);
toast.error("Erro ao carregar dados do perfil");
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!user?.id) return;
try {
const dataToSave = {
...formData,
experience_years: formData.experience_years
? parseInt(formData.experience_years)
: undefined,
};
await doctorService.update(user.id, dataToSave);
toast.success("Perfil atualizado com sucesso!");
setIsEditing(false);
} catch (error) {
console.error("Erro ao salvar perfil:", error);
toast.error("Erro ao salvar perfil");
}
};
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handlePasswordChange = async () => {
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
if (passwordData.newPassword.length < 6) {
toast.error("A senha deve ter pelo menos 6 caracteres");
return;
}
try {
// TODO: Implementar mudança de senha via API
toast.success("Senha alterada com sucesso!");
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (error) {
console.error("Erro ao alterar senha:", error);
toast.error("Erro ao alterar senha");
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-green-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
<p className="text-gray-600">
Gerencie suas informações pessoais e profissionais
</p>
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Editar Perfil
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => {
setIsEditing(false);
loadDoctorData();
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
<Save className="w-4 h-4" />
Salvar
</button>
</div>
)}
</div>
{/* Avatar Card */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
<div className="flex items-center gap-6">
<AvatarUpload
userId={user?.id}
currentAvatarUrl={avatarUrl}
name={formData.full_name || "Médico"}
color="green"
size="xl"
editable={true}
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
/>
<div>
<p className="font-medium text-gray-900">{formData.full_name}</p>
<p className="text-gray-500">{formData.specialty}</p>
<p className="text-sm text-gray-500">
CRM: {formData.crm} - {formData.crm_state}
</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab("personal")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "personal"
? "border-green-600 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Dados Pessoais
</button>
<button
onClick={() => setActiveTab("professional")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "professional"
? "border-green-600 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Informações Profissionais
</button>
<button
onClick={() => setActiveTab("security")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "security"
? "border-green-600 text-green-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Segurança
</button>
</nav>
</div>
<div className="p-6">
{/* Tab: Dados Pessoais */}
{activeTab === "personal" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
Informações Pessoais
</h3>
<p className="text-sm text-gray-500 mb-4">
Mantenha seus dados atualizados
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome Completo
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) =>
handleChange("full_name", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CPF
</label>
<input
type="text"
value={formData.cpf}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data de Nascimento
</label>
<input
type="date"
value={formData.birth_date}
onChange={(e) =>
handleChange("birth_date", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Gênero
</label>
<select
value={formData.gender}
onChange={(e) => handleChange("gender", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="">Selecione</option>
<option value="male">Masculino</option>
<option value="female">Feminino</option>
<option value="other">Outro</option>
</select>
</div>
</div>
</div>
</div>
)}
{/* Tab: Informações Profissionais */}
{activeTab === "professional" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
Informações Profissionais
</h3>
<p className="text-sm text-gray-500 mb-4">
Dados da sua carreira médica
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Especialidade
</label>
<input
type="text"
value={formData.specialty}
onChange={(e) =>
handleChange("specialty", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CRM
</label>
<input
type="text"
value={formData.crm}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado do CRM
</label>
<input
type="text"
value={formData.crm_state}
disabled
maxLength={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anos de Experiência
</label>
<input
type="number"
value={formData.experience_years}
onChange={(e) =>
handleChange("experience_years", e.target.value)
}
disabled={!isEditing}
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Biografia
</label>
<textarea
value={formData.bio}
onChange={(e) => handleChange("bio", e.target.value)}
disabled={!isEditing}
placeholder="Conte um pouco sobre sua trajetória profissional..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Formação Acadêmica
</label>
<textarea
value={formData.education}
onChange={(e) =>
handleChange("education", e.target.value)
}
disabled={!isEditing}
placeholder="Universidades, residências, especializações..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
</div>
)}
{/* Tab: Segurança */}
{activeTab === "security" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Alterar Senha</h3>
<p className="text-sm text-gray-500 mb-4">
Mantenha sua conta segura
</p>
<div className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Senha Atual
</label>
<input
type="password"
value={passwordData.currentPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
currentPassword: e.target.value,
})
}
placeholder="Digite sua senha atual"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nova Senha
</label>
<input
type="password"
value={passwordData.newPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
newPassword: e.target.value,
})
}
placeholder="Digite a nova senha"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirmar Nova Senha
</label>
<input
type="password"
value={passwordData.confirmPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
confirmPassword: e.target.value,
})
}
placeholder="Confirme a nova senha"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handlePasswordChange}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Alterar Senha
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,597 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import toast from "react-hot-toast";
import { useAuth } from "../hooks/useAuth";
import { patientService } from "../services";
import { AvatarUpload } from "../components/ui/AvatarUpload";
export default function PerfilPaciente() {
const { user } = useAuth();
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<
"personal" | "medical" | "security"
>("personal");
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState({
full_name: "",
email: "",
phone_mobile: "",
cpf: "",
birth_date: "",
sex: "",
street: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
cep: "",
blood_type: "",
weight_kg: "",
height_m: "",
});
const [passwordData, setPasswordData] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
useEffect(() => {
if (user?.id) {
loadPatientData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const loadPatientData = async () => {
if (!user?.id) return;
try {
setLoading(true);
const patient = await patientService.getById(user.id);
if (patient) {
setFormData({
full_name: patient.full_name || "",
email: patient.email || "",
phone_mobile: patient.phone_mobile || "",
cpf: patient.cpf || "",
birth_date: patient.birth_date || "",
sex: patient.sex || "",
street: patient.street || "",
number: patient.number || "",
complement: patient.complement || "",
neighborhood: patient.neighborhood || "",
city: patient.city || "",
state: patient.state || "",
cep: patient.cep || "",
blood_type: patient.blood_type || "",
weight_kg: patient.weight_kg?.toString() || "",
height_m: patient.height_m?.toString() || "",
});
// Patient type não tem avatar_url ainda
setAvatarUrl(undefined);
}
} catch (error) {
console.error("Erro ao carregar dados do paciente:", error);
toast.error("Erro ao carregar dados do perfil");
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!user?.id) return;
try {
const dataToSave = {
...formData,
weight_kg: formData.weight_kg
? parseFloat(formData.weight_kg)
: undefined,
height_m: formData.height_m ? parseFloat(formData.height_m) : undefined,
};
await patientService.update(user.id, dataToSave);
toast.success("Perfil atualizado com sucesso!");
setIsEditing(false);
} catch (error) {
console.error("Erro ao salvar perfil:", error);
toast.error("Erro ao salvar perfil");
}
};
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handlePasswordChange = async () => {
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
if (passwordData.newPassword.length < 6) {
toast.error("A senha deve ter pelo menos 6 caracteres");
return;
}
try {
// TODO: Implementar mudança de senha via API
toast.success("Senha alterada com sucesso!");
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (error) {
console.error("Erro ao alterar senha:", error);
toast.error("Erro ao alterar senha");
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
<p className="text-gray-600">
Gerencie suas informações pessoais e médicas
</p>
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Editar Perfil
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => {
setIsEditing(false);
loadPatientData();
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Save className="w-4 h-4" />
Salvar
</button>
</div>
)}
</div>
{/* Avatar Card */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
<div className="flex items-center gap-6">
<AvatarUpload
userId={user?.id}
currentAvatarUrl={avatarUrl}
name={formData.full_name || "Paciente"}
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
/>
<div>
<p className="font-medium text-gray-900">{formData.full_name}</p>
<p className="text-gray-500">{formData.email}</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab("personal")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "personal"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Dados Pessoais
</button>
<button
onClick={() => setActiveTab("medical")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "medical"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Informações Médicas
</button>
<button
onClick={() => setActiveTab("security")}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "security"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Segurança
</button>
</nav>
</div>
<div className="p-6">
{/* Tab: Dados Pessoais */}
{activeTab === "personal" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
Informações Pessoais
</h3>
<p className="text-sm text-gray-500 mb-4">
Mantenha seus dados atualizados
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome Completo
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) =>
handleChange("full_name", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<input
type="tel"
value={formData.phone_mobile}
onChange={(e) =>
handleChange("phone_mobile", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CPF
</label>
<input
type="text"
value={formData.cpf}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data de Nascimento
</label>
<input
type="date"
value={formData.birth_date}
onChange={(e) =>
handleChange("birth_date", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sexo
</label>
<select
value={formData.sex}
onChange={(e) => handleChange("sex", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="">Selecione</option>
<option value="M">Masculino</option>
<option value="F">Feminino</option>
<option value="O">Outro</option>
</select>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4">Endereço</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Rua
</label>
<input
type="text"
value={formData.street}
onChange={(e) => handleChange("street", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Número
</label>
<input
type="text"
value={formData.number}
onChange={(e) => handleChange("number", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Complemento
</label>
<input
type="text"
value={formData.complement}
onChange={(e) =>
handleChange("complement", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bairro
</label>
<input
type="text"
value={formData.neighborhood}
onChange={(e) =>
handleChange("neighborhood", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cidade
</label>
<input
type="text"
value={formData.city}
onChange={(e) => handleChange("city", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange("state", e.target.value)}
disabled={!isEditing}
maxLength={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CEP
</label>
<input
type="text"
value={formData.cep}
onChange={(e) => handleChange("cep", e.target.value)}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Tab: Informações Médicas */}
{activeTab === "medical" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
Informações Médicas
</h3>
<p className="text-sm text-gray-500 mb-4">
Dados importantes para atendimento médico
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo Sanguíneo
</label>
<select
value={formData.blood_type}
onChange={(e) =>
handleChange("blood_type", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="">Selecione</option>
<option value="A+">A+</option>
<option value="A-">A-</option>
<option value="B+">B+</option>
<option value="B-">B-</option>
<option value="AB+">AB+</option>
<option value="AB-">AB-</option>
<option value="O+">O+</option>
<option value="O-">O-</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Peso (kg)
</label>
<input
type="number"
step="0.1"
value={formData.weight_kg}
onChange={(e) =>
handleChange("weight_kg", e.target.value)
}
disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Altura (m)
</label>
<input
type="number"
step="0.01"
value={formData.height_m}
onChange={(e) =>
handleChange("height_m", e.target.value)
}
disabled={!isEditing}
placeholder="Ex: 1.75"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Tab: Segurança */}
{activeTab === "security" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Alterar Senha</h3>
<p className="text-sm text-gray-500 mb-4">
Mantenha sua conta segura
</p>
<div className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Senha Atual
</label>
<input
type="password"
value={passwordData.currentPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
currentPassword: e.target.value,
})
}
placeholder="Digite sua senha atual"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nova Senha
</label>
<input
type="password"
value={passwordData.newPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
newPassword: e.target.value,
})
}
placeholder="Digite a nova senha"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirmar Nova Senha
</label>
<input
type="password"
value={passwordData.confirmPassword}
onChange={(e) =>
setPasswordData({
...passwordData,
confirmPassword: e.target.value,
})
}
placeholder="Confirme a nova senha"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
onClick={handlePasswordChange}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Alterar Senha
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,15 +1,23 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import toast from "react-hot-toast";
import { consultasService, type Consulta } from "../services/consultasService";
import {
getPatientById,
listPatientAttachments,
addPatientAttachment,
removePatientAttachment,
type Paciente as PacienteServiceModel,
type Anexo,
} from "../services/pacienteService";
appointmentService,
type Appointment,
patientService,
type Patient as PacienteServiceModel,
} from "../services";
// Legacy type for compatibility
type Consulta = Appointment;
type Anexo = {
id: string;
nome: string;
tipo: string;
tamanho: number;
url: string;
data: string;
};
interface ExtendedPacienteMeta {
rg?: string;
@ -53,13 +61,10 @@ const ProntuarioPaciente = () => {
if (!id) return;
setLoading(true);
try {
const respPaciente = await getPatientById(id);
const patient = await patientService.getById(id);
if (!mounted) return;
if (respPaciente.success && respPaciente.data) {
setPaciente(respPaciente.data);
} else {
throw new Error(respPaciente.error || "Paciente não encontrado");
}
setPaciente(patient);
// metadata local
try {
const raw = localStorage.getItem("pacientes_meta") || "{}";
@ -71,19 +76,17 @@ const ProntuarioPaciente = () => {
} catch {
setMeta(null);
}
// consultas (últimas + futuras limitadas)
const respConsultas = await consultasService.listarPorPaciente(id, {
const appointments = await appointmentService.list({
patient_id: id,
limit: 20,
order: "scheduled_at.desc",
});
if (respConsultas.success && respConsultas.data)
setConsultas(respConsultas.data);
// anexos
try {
const anexosList = await listPatientAttachments(id);
setAnexos(anexosList);
} catch {
console.warn("Falha ao carregar anexos");
}
setConsultas(appointments);
// anexos (placeholder - not yet implemented in backend)
setAnexos([]);
// histórico (placeholder - poderá ser alimentado quando audit trail existir)
const histRaw = localStorage.getItem(`paciente_hist_${id}`) || "[]";
try {
@ -105,24 +108,28 @@ const ProntuarioPaciente = () => {
}, [id, navigate]);
const consultasOrdenadas = useMemo(() => {
return [...consultas].sort((a, b) => b.dataHora.localeCompare(a.dataHora));
return [...consultas].sort((a, b) =>
(b.scheduled_at || "").localeCompare(a.scheduled_at || "")
);
}, [consultas]);
const ultimaConsulta = consultasOrdenadas.find(() => true);
const proximaConsulta = useMemo(() => {
const agora = new Date().toISOString();
return consultasOrdenadas
.filter((c) => c.dataHora >= agora)
.sort((a, b) => a.dataHora.localeCompare(b.dataHora))[0];
.filter((c) => (c.scheduled_at || "") >= agora)
.sort((a, b) =>
(a.scheduled_at || "").localeCompare(b.scheduled_at || "")
)[0];
}, [consultasOrdenadas]);
const idade = useMemo(() => {
if (!paciente?.dataNascimento) return null;
const d = new Date(paciente.dataNascimento);
if (!paciente?.birth_date) return null;
const d = new Date(paciente.birth_date);
if (Number.isNaN(d.getTime())) return null;
const diff = Date.now() - d.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));
}, [paciente?.dataNascimento]);
}, [paciente?.birth_date]);
const handleUpload = async (ev: React.ChangeEvent<HTMLInputElement>) => {
if (!id) return;
@ -130,11 +137,8 @@ const ProntuarioPaciente = () => {
if (!files || files.length === 0) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const anexo = await addPatientAttachment(id, file);
setAnexos((a) => [...a, anexo]);
}
toast.success("Anexo(s) enviado(s) com sucesso");
// Attachment upload not yet implemented in backend
toast("Funcionalidade de anexos em desenvolvimento");
} catch {
toast.error("Falha ao enviar anexo");
} finally {
@ -147,9 +151,8 @@ const ProntuarioPaciente = () => {
if (!id) return;
if (!confirm(`Remover anexo "${anexo.nome || anexo.id}"?`)) return;
try {
await removePatientAttachment(id, anexo.id);
setAnexos((as) => as.filter((a) => a.id !== anexo.id));
toast.success("Anexo removido");
// Attachment deletion not yet implemented in backend
toast("Funcionalidade de anexos em desenvolvimento");
} catch {
toast.error("Erro ao remover anexo");
}
@ -207,7 +210,7 @@ const ProntuarioPaciente = () => {
&larr; Voltar
</button>
<h1 className="text-2xl font-semibold mt-1">
Prontuário: {paciente.nome}
Prontuário: {paciente.full_name}
</h1>
<p className="text-sm text-gray-500">
CPF: {paciente.cpf || "—"} {idade ? `${idade} anos` : ""}
@ -229,34 +232,33 @@ const ProntuarioPaciente = () => {
<h2 className="font-semibold mb-3">Visão Geral</h2>
<ul className="text-sm space-y-1">
<li>
Última consulta: {formatDataHora(ultimaConsulta?.dataHora)}
Última consulta: {formatDataHora(ultimaConsulta?.scheduled_at)}
</li>
<li>
Próxima consulta: {formatDataHora(proximaConsulta?.dataHora)}
Próxima consulta:{" "}
{formatDataHora(proximaConsulta?.scheduled_at)}
</li>
<li>Convênio: {paciente.convenio || "Particular"}</li>
<li>VIP: {paciente.vip ? "Sim" : "Não"}</li>
<li>Tipo sanguíneo: {paciente.tipoSanguineo || "—"}</li>
<li>Convênio: Particular</li>
<li>VIP: Não</li>
<li>Tipo sanguíneo: {paciente.blood_type || "—"}</li>
</ul>
</div>
<div className="p-4 bg-white rounded-xl shadow border border-gray-200">
<h2 className="font-semibold mb-3">Contato</h2>
<ul className="text-sm space-y-1">
<li>Email: {paciente.email || "—"}</li>
<li>Telefone: {paciente.telefone || "—"}</li>
<li>Telefone: {paciente.phone_mobile || "—"}</li>
<li>
Endereço:{" "}
{paciente.endereco?.rua
? `${paciente.endereco.rua}, ${
paciente.endereco.numero || "s/n"
} - ${paciente.endereco.bairro || ""}`
{paciente.street
? `${paciente.street}, ${paciente.number || "s/n"} - ${
paciente.neighborhood || ""
}`
: "—"}
</li>
<li>
Cidade/UF: {paciente.endereco?.cidade || ""}
{paciente.endereco?.estado
? `/${paciente.endereco.estado}`
: ""}
Cidade/UF: {paciente.city || ""}
{paciente.state ? `/${paciente.state}` : ""}
</li>
</ul>
</div>
@ -325,7 +327,7 @@ const ProntuarioPaciente = () => {
>
<div>
<p className="font-medium">
{formatDataHora(c.dataHora)} {c.tipo && `${c.tipo}`}
{formatDataHora(c.scheduled_at)}
</p>
<p className="text-gray-500">Status: {c.status}</p>
</div>
@ -370,7 +372,7 @@ const ProntuarioPaciente = () => {
<div>
<p className="font-medium">{a.nome || a.id}</p>
<p className="text-gray-500 text-xs">
{a.tipo || a.categoria || "arquivo"}{" "}
{a.tipo || "arquivo"}{" "}
{a.tamanho ? `${(a.tamanho / 1024).toFixed(1)} KB` : ""}
</p>
</div>

View File

@ -1,113 +0,0 @@
import React, { useState } from "react";
import userService from "../services/userService";
import toast from "react-hot-toast";
import { ApiResponse } from "../services/http";
const TesteCadastroSquad18: React.FC = () => {
const [loading, setLoading] = useState(false);
const [resultado, setResultado] = useState<ApiResponse<{
id: string;
email: string;
}> | null>(null);
const handleTestar = async () => {
setLoading(true);
setResultado(null);
try {
console.log("🧪 [TESTE SQUAD 18] Iniciando cadastro...");
const result = await userService.signupPaciente({
nome: "Paciente Teste SQUAD 18",
email: "teste.squad18@clinica.com",
password: "123456",
telefone: "11999998888",
cpf: "12345678900",
dataNascimento: "1990-01-01",
endereco: {
cep: "01310100",
rua: "Avenida Paulista",
numero: "1000",
bairro: "Bela Vista",
cidade: "São Paulo",
estado: "SP",
},
});
console.log("🎯 [TESTE SQUAD 18] Resultado:", result);
setResultado(result);
if (result.success) {
toast.success("✅ Paciente SQUAD 18 cadastrado com sucesso na API!");
} else {
toast.error(`❌ Erro: ${result.error}`);
}
} catch (error) {
console.error("💥 [TESTE SQUAD 18] Erro:", error);
const errorMessage =
error instanceof Error ? error.message : "Erro desconhecido";
setResultado({ success: false, error: errorMessage });
toast.error("Erro ao cadastrar");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-white flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
🧪 Teste de Cadastro SQUAD 18
</h1>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h2 className="font-semibold text-blue-900 mb-2">Dados do Teste:</h2>
<ul className="text-sm text-blue-800 space-y-1">
<li> Email: teste.squad18@clinica.com</li>
<li>👤 Nome: Paciente Teste SQUAD 18</li>
<li>🔑 Senha: 123456</li>
<li>📞 Telefone: 11999998888</li>
<li>📍 Endpoint: /auth/v1/signup</li>
</ul>
</div>
<button
onClick={handleTestar}
disabled={loading}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 transition-all shadow-lg mb-6"
>
{loading ? "⏳ Testando..." : "🚀 Executar Teste de Cadastro"}
</button>
{resultado && (
<div
className={`rounded-lg p-6 ${
resultado.success
? "bg-green-50 border border-green-200"
: "bg-red-50 border border-red-200"
}`}
>
<h3
className={`font-bold text-lg mb-3 ${
resultado.success ? "text-green-900" : "text-red-900"
}`}
>
{resultado.success ? "✅ SUCESSO!" : "❌ ERRO"}
</h3>
<pre className="text-sm overflow-auto bg-white p-4 rounded border">
{JSON.stringify(resultado, null, 2)}
</pre>
</div>
)}
<div className="mt-6 text-center">
<a href="/" className="text-blue-600 hover:text-blue-800 underline">
Voltar para Home
</a>
</div>
</div>
</div>
);
};
export default TesteCadastroSquad18;

View File

@ -1,6 +1,17 @@
import React, { useEffect, useState } from "react";
import { decodeJwt } from "../services/tokenDebug";
import authService from "../services/authService";
import { authService } from "../services";
// Simple JWT decoder
const decodeJwt = (token: string) => {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1]));
return payload;
} catch {
return null;
}
};
interface DecodedInfo {
exp?: number;
@ -37,16 +48,20 @@ const TokenInspector: React.FC = () => {
const handleRefresh = async () => {
setRefreshing(true);
setError(null);
const resp = await authService.refreshToken();
if (!resp.success) setError(resp.error || "Falha ao renovar");
load();
setRefreshing(false);
try {
await authService.refreshToken();
load();
} catch (err) {
setError(err instanceof Error ? err.message : "Falha ao renovar");
} finally {
setRefreshing(false);
}
};
const payload: DecodedInfo | undefined = decoded?.payload;
const expired = decoded?.expired;
const payload: DecodedInfo | undefined = decoded;
const exp = payload?.exp;
const iat = payload?.iat;
const expired = exp ? Date.now() > exp * 1000 : false;
return (
<div className="max-w-3xl mx-auto space-y-6">

View File

@ -1,174 +0,0 @@
// Service para funcionalidades administrativas
import { http, ApiResponse } from "./http";
import ENDPOINTS from "./endpoints";
export type RoleType = "admin" | "gestor" | "medico" | "secretaria" | "user";
export interface UserRoleData {
id?: string;
user_id: string;
role: RoleType;
created_at?: string;
}
export interface CreateUserInput {
email: string;
password: string;
full_name: string;
phone?: string | null;
role: RoleType;
}
export interface CreateUserResponse {
success: boolean;
user?: {
id: string;
email: string;
full_name: string;
phone?: string | null;
role: string;
};
error?: string;
}
// Listar roles de usuários
export async function listUserRoles(params?: {
user_id?: string;
role?: RoleType;
}): Promise<ApiResponse<UserRoleData[]>> {
try {
const queryParams: Record<string, string> = { select: "*" };
if (params?.user_id) {
queryParams["user_id"] = `eq.${params.user_id}`;
}
if (params?.role) {
queryParams["role"] = `eq.${params.role}`;
}
const response = await http.get<UserRoleData[]>(ENDPOINTS.USER_ROLES, {
params: queryParams,
});
if (response.success && response.data) {
return {
success: true,
data: Array.isArray(response.data) ? response.data : [response.data],
};
}
return {
success: false,
error: response.error || "Erro ao listar roles",
};
} catch (error) {
console.error("Erro ao listar roles:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
// Criar novo usuário (via Edge Function)
export async function createUser(
data: CreateUserInput
): Promise<CreateUserResponse> {
try {
const response = await http.post<CreateUserResponse>(
"/functions/v1/create-user",
data
);
if (response.success && response.data) {
return response.data;
}
return {
success: false,
error: response.error || "Erro ao criar usuário",
};
} catch (error) {
console.error("Erro ao criar usuário:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Erro ao criar usuário",
};
}
}
// Adicionar role a um usuário
export async function addUserRole(
user_id: string,
role: RoleType
): Promise<ApiResponse<UserRoleData>> {
try {
const data = {
user_id,
role,
};
const response = await http.post<UserRoleData>(ENDPOINTS.USER_ROLES, data, {
headers: { Prefer: "return=representation" },
});
if (response.success && response.data) {
const userRole = Array.isArray(response.data)
? response.data[0]
: response.data;
return {
success: true,
data: userRole,
message: "Role adicionada com sucesso",
};
}
return {
success: false,
error: response.error || "Erro ao adicionar role",
};
} catch (error) {
console.error("Erro ao adicionar role:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Erro ao adicionar role",
};
}
}
// Remover role de um usuário
export async function removeUserRole(
roleId: string
): Promise<ApiResponse<void>> {
try {
const response = await http.delete(
`${ENDPOINTS.USER_ROLES}?id=eq.${roleId}`
);
if (response.success) {
return {
success: true,
message: "Role removida com sucesso",
};
}
return {
success: false,
error: response.error || "Erro ao remover role",
};
} catch (error) {
console.error("Erro ao remover role:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Erro ao remover role",
};
}
}
export default {
listUserRoles,
createUser,
addUserRole,
removeUserRole,
};

View File

@ -1,429 +0,0 @@
import api from "./api";
import { ApiResponse } from "./http";
export interface UserInfo {
id: string;
email: string;
email_confirmed_at?: string | null;
created_at: string;
last_sign_in_at?: string | null;
}
export interface UserProfile {
id: string;
full_name?: string | null;
email?: string | null;
phone?: string | null;
avatar_url?: string | null;
disabled?: boolean;
created_at?: string;
updated_at?: string;
}
export interface UserPermissions {
isAdmin?: boolean;
isManager?: boolean;
isDoctor?: boolean;
isSecretary?: boolean;
isAdminOrManager?: boolean;
}
export interface FullUserInfo {
user: UserInfo;
profile?: UserProfile | null;
roles?: string[];
permissions?: UserPermissions;
}
export interface UserRole {
id: string;
user_id: string;
role: "admin" | "gestor" | "medico" | "secretaria" | "user";
created_at: string;
}
export interface UpdateUserData {
full_name?: string;
phone?: string;
email?: string;
disabled?: boolean;
roles?: string[];
}
class AdminUserService {
/**
* Busca informações do usuário autenticado
*/
async getCurrentUserInfo(): Promise<ApiResponse<FullUserInfo>> {
try {
console.log(
"[adminUserService] Buscando informações do usuário atual..."
);
const response = await api.get<FullUserInfo>("/functions/v1/user-info");
console.log("[adminUserService] Usuário atual:", response.data);
return {
success: true,
data: response.data,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao buscar usuário:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao buscar informações do usuário";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Busca roles de um usuário
*/
async getUserRoles(userId: string): Promise<ApiResponse<UserRole[]>> {
try {
console.log("[adminUserService] Buscando roles do usuário:", userId);
const response = await api.get(
`/rest/v1/user_roles?user_id=eq.${userId}`
);
console.log("[adminUserService] Roles encontrados:", response.data);
return {
success: true,
data: response.data,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao buscar roles:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao buscar roles do usuário";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Busca roles de todos os usuários
*/
async getAllUserRoles(): Promise<ApiResponse<UserRole[]>> {
try {
console.log("[adminUserService] Buscando todos os roles...");
const response = await api.get("/rest/v1/user_roles?select=*");
console.log("[adminUserService] Total de roles:", response.data.length);
return {
success: true,
data: response.data,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao buscar roles:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao buscar roles";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Adiciona um role a um usuário
*/
async addUserRole(
userId: string,
role: "admin" | "gestor" | "medico" | "secretaria" | "user"
): Promise<ApiResponse<UserRole>> {
try {
console.log("[adminUserService] Adicionando role:", { userId, role });
const response = await api.post(
"/rest/v1/user_roles",
{
user_id: userId,
role: role,
},
{
headers: {
Prefer: "return=representation",
},
}
);
console.log("[adminUserService] Role adicionado:", response.data);
return {
success: true,
data: response.data[0],
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao adicionar role:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao adicionar role";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Remove um role de um usuário
*/
async removeUserRole(roleId: string): Promise<ApiResponse<void>> {
try {
console.log("[adminUserService] Removendo role:", roleId);
await api.delete(`/rest/v1/user_roles?id=eq.${roleId}`);
console.log("[adminUserService] Role removido com sucesso");
return {
success: true,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao remover role:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao remover role";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Lista todos os usuários (requer permissão de admin)
*/
async listAllUsers(): Promise<ApiResponse<FullUserInfo[]>> {
try {
console.log("[adminUserService] Listando todos os usuários...");
// Buscar da tabela profiles
const profilesResponse = await api.get("/rest/v1/profiles?select=*");
// Buscar todos os roles
const rolesResult = await this.getAllUserRoles();
const allRoles =
rolesResult.success && rolesResult.data ? rolesResult.data : [];
console.log(
"[adminUserService] Total de usuários:",
profilesResponse.data.length
);
console.log("[adminUserService] Total de roles:", allRoles.length);
// Criar mapa de roles por usuário
const rolesMap = new Map<string, string[]>();
allRoles.forEach((userRole: UserRole) => {
if (!rolesMap.has(userRole.user_id)) {
rolesMap.set(userRole.user_id, []);
}
rolesMap.get(userRole.user_id)?.push(userRole.role);
});
// Transformar para formato FullUserInfo
const users: FullUserInfo[] = profilesResponse.data.map(
(profile: UserProfile) => {
const roles = rolesMap.get(profile.id) || [];
const permissions: UserPermissions = {
isAdmin: roles.includes("admin"),
isManager: roles.includes("gestor"),
isDoctor: roles.includes("medico"),
isSecretary: roles.includes("secretaria"),
isAdminOrManager:
roles.includes("admin") || roles.includes("gestor"),
};
return {
user: {
id: profile.id,
email: profile.email || "",
created_at: profile.created_at || "",
},
profile: profile,
roles: roles,
permissions: permissions,
};
}
);
return {
success: true,
data: users,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao listar usuários:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao listar usuários";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Atualiza dados de um usuário na tabela profiles
*/
async updateUser(
userId: string,
data: UpdateUserData
): Promise<ApiResponse<UserProfile>> {
try {
console.log("[adminUserService] Atualizando usuário:", userId, data);
const updateData: Partial<UserProfile> = {};
if (data.full_name !== undefined) updateData.full_name = data.full_name;
if (data.phone !== undefined) updateData.phone = data.phone;
if (data.email !== undefined) updateData.email = data.email;
if (data.disabled !== undefined) updateData.disabled = data.disabled;
const response = await api.patch(
`/rest/v1/profiles?id=eq.${userId}`,
updateData,
{
headers: {
Prefer: "return=representation",
},
}
);
console.log("[adminUserService] Usuário atualizado:", response.data);
return {
success: true,
data: response.data[0],
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao atualizar usuário:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao atualizar usuário";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Desabilita um usuário (soft delete)
*/
async disableUser(userId: string): Promise<ApiResponse<UserProfile>> {
return this.updateUser(userId, { disabled: true });
}
/**
* Habilita um usuário
*/
async enableUser(userId: string): Promise<ApiResponse<UserProfile>> {
return this.updateUser(userId, { disabled: false });
}
/**
* Deleta um usuário permanentemente
*/
async deleteUser(userId: string): Promise<ApiResponse<void>> {
try {
console.log("[adminUserService] Deletando usuário:", userId);
await api.delete(`/rest/v1/profiles?id=eq.${userId}`);
console.log("[adminUserService] Usuário deletado com sucesso");
return {
success: true,
};
} catch (error: unknown) {
console.error("[adminUserService] Erro ao deletar usuário:", error);
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
"Erro ao deletar usuário";
return {
success: false,
error: errorMessage,
};
}
}
}
export const adminUserService = new AdminUserService();
export default adminUserService;

View File

@ -1,94 +0,0 @@
import axios, { type AxiosInstance } from "axios";
import { SUPABASE_URL, SUPABASE_ANON_KEY } from "./supabaseConfig";
import tokenStore from "./tokenStore";
import { logger } from "./logger";
// Config Supabase
// Permite sobrescrever via variáveis Vite (.env) mantendo fallback para a URL e chave fornecidas.
// Valores agora centralizados em supabaseConfig.ts
// Base principal (REST); os services já usam prefixo /rest/v1/
const api: AxiosInstance = axios.create({
baseURL: SUPABASE_URL,
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
},
});
// Incluir Authorization se existir token salvo
api.interceptors.request.use(
async (config) => {
try {
config.headers = config.headers || {};
const headers = config.headers as Record<string, string>;
if (!headers.apikey) headers.apikey = SUPABASE_ANON_KEY;
const lowered = (config.url || "").toLowerCase();
const isAuthEndpoint =
lowered.includes("/auth/v1/token") ||
lowered.includes("/auth/v1/signup");
const token = tokenStore.getAccessToken();
if (token && !isAuthEndpoint) {
// Validar se token não está expirado antes de enviar
try {
const parts = token.split(".");
if (parts.length === 3) {
const payload = JSON.parse(atob(parts[1]));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
logger.warn("token expirado detectado - removendo authorization");
tokenStore.clear();
// Não adicionar Authorization com token expirado — API rejeita com "No API key found"
} else {
headers.Authorization = `Bearer ${token}`;
}
} else {
headers.Authorization = `Bearer ${token}`;
}
} catch {
headers.Authorization = `Bearer ${token}`; // fallback se decode falhar
}
}
// IMPORTANTE: Nunca usar anon key como Bearer token — API exige token de usuário válido ou ausente
// Prefer header removido - causava erro CORS em /functions/v1/user-info
} catch (e) {
logger.warn("request interceptor error", {
error: (e as Error)?.message,
});
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de resposta para diagnosticar 401 e confirmar headers efetivos
api.interceptors.response.use(
(resp) => resp,
(error) => {
try {
const status = error?.response?.status;
if (status === 401) {
const cfg = error.config || {};
const h = (cfg.headers || {}) as Record<string, string>;
const msg =
error?.response?.data?.message ||
error?.response?.data?.error ||
"Unauthorized";
if (!h.apikey) {
logger.error("401 missing apikey", { msg });
} else if (!h.Authorization) {
logger.warn("401 missing bearer token", { msg });
}
}
} catch {
/* ignore */
}
return Promise.reject(error);
}
);
export default api;

View File

@ -0,0 +1,200 @@
/**
* Cliente HTTP usando Axios
* Todas as requisições passam pelas Netlify Functions
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { API_CONFIG } from "./config";
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_CONFIG.BASE_URL,
timeout: API_CONFIG.TIMEOUT,
headers: {
"Content-Type": "application/json",
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor - adiciona token automaticamente
this.client.interceptors.request.use(
(config: any) => {
// Não adicionar token se a flag _skipAuth estiver presente
if (config._skipAuth) {
delete config._skipAuth;
return config;
}
const token = localStorage.getItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN
);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - trata erros globalmente
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Se retornar 401 e não for uma requisição de refresh/login
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes("auth-refresh") &&
!originalRequest.url?.includes("auth-login")
) {
originalRequest._retry = true;
try {
// Tenta renovar o token
const refreshToken = localStorage.getItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN
);
if (refreshToken) {
const response = await this.client.post("/auth-refresh", {
refresh_token: refreshToken,
});
const {
access_token,
refresh_token: newRefreshToken,
user,
} = response.data;
// Atualiza tokens
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
access_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
newRefreshToken
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.USER,
JSON.stringify(user)
);
// Atualiza o header da requisição original
originalRequest.headers.Authorization = `Bearer ${access_token}`;
// Refaz a requisição original
return this.client(originalRequest);
}
} catch (refreshError) {
// Se refresh falhar, limpa tudo e redireciona para home
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
// Redireciona para home ao invés de login específico
if (
!window.location.pathname.includes("/login") &&
window.location.pathname !== "/"
) {
window.location.href = "/";
}
return Promise.reject(refreshError);
}
}
// Se não conseguir renovar, limpa e redireciona para home
if (error.response?.status === 401) {
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
// Redireciona para home ao invés de login específico
if (
!window.location.pathname.includes("/login") &&
window.location.pathname !== "/"
) {
window.location.href = "/";
}
}
return Promise.reject(error);
}
);
}
// Métodos HTTP
async get<T>(
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config);
}
async post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config);
}
/**
* POST público - não envia token de autenticação
* Usado para endpoints de auto-registro
*/
async postPublic<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
const configWithoutAuth = {
...config,
headers: {
...config?.headers,
// Remove Authorization header
},
// Flag especial para o interceptor saber que não deve adicionar token
_skipAuth: true,
};
return this.client.post<T>(url, data, configWithoutAuth);
}
async patch<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config);
}
async delete<T>(
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, config);
}
async put<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
}
}
export const apiClient = new ApiClient();

View File

@ -0,0 +1,26 @@
/**
* Configuração da API
* Frontend sempre chama Netlify Functions (não o Supabase direto)
*/
// Em desenvolvimento, Netlify Dev roda na porta 8888
// Em produção, usa caminho relativo
const isDevelopment = import.meta.env.DEV;
const BASE_URL = isDevelopment
? "http://localhost:8888/.netlify/functions"
: "/.netlify/functions";
export const API_CONFIG = {
// Base URL aponta para suas Netlify Functions
BASE_URL,
// Timeout padrão (30 segundos)
TIMEOUT: 30000,
// Storage keys
STORAGE_KEYS: {
ACCESS_TOKEN: "mediconnect_access_token",
REFRESH_TOKEN: "mediconnect_refresh_token",
USER: "mediconnect_user",
},
} as const;

View File

@ -1,345 +0,0 @@
/**
* Service para gerenciar agendamentos (appointments)
* API completa com horários disponíveis, CRUD de agendamentos
*/
import { http, ApiResponse } from "./http";
import ENDPOINTS from "./endpoints";
import authService from "./authService";
export type AppointmentStatus =
| "requested"
| "confirmed"
| "checked_in"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export type AppointmentType = "presencial" | "telemedicina";
export interface Appointment {
id?: string;
order_number?: string;
patient_id?: string;
doctor_id?: string;
scheduled_at?: string; // ISO 8601
duration_minutes?: number;
appointment_type?: AppointmentType;
status?: AppointmentStatus;
chief_complaint?: string | null;
patient_notes?: string | null;
notes?: string | null;
insurance_provider?: string | null;
checked_in_at?: string | null;
completed_at?: string | null;
cancelled_at?: string | null;
cancellation_reason?: string | null;
created_at?: string;
updated_at?: string;
created_by?: string;
updated_by?: string | null;
}
export interface CreateAppointmentInput {
patient_id: string;
doctor_id: string;
scheduled_at: string; // ISO 8601
duration_minutes?: number;
appointment_type?: AppointmentType;
chief_complaint?: string;
patient_notes?: string;
insurance_provider?: string;
}
export interface UpdateAppointmentInput {
scheduled_at?: string;
duration_minutes?: number;
status?: AppointmentStatus;
chief_complaint?: string;
notes?: string;
patient_notes?: string;
insurance_provider?: string;
checked_in_at?: string;
completed_at?: string;
cancelled_at?: string;
cancellation_reason?: string;
}
export interface AvailableSlotRequest {
doctor_id: string;
start_date: string; // ISO 8601
end_date: string; // ISO 8601
appointment_type?: AppointmentType;
}
export interface AvailableSlot {
datetime: string; // ISO 8601
available: boolean;
}
export interface AvailableSlotsResponse {
slots: AvailableSlot[];
}
export interface ListAppointmentsParams {
select?: string;
doctor_id?: string;
patient_id?: string;
status?: AppointmentStatus;
scheduled_at?: string; // Usar operadores como gte.2025-10-10
order?: string;
limit?: number;
offset?: number;
}
class AppointmentService {
async getAvailableSlots(
params: AvailableSlotRequest
): Promise<ApiResponse<AvailableSlotsResponse>> {
try {
const response = await http.post<AvailableSlotsResponse>(
ENDPOINTS.AVAILABLE_SLOTS,
params
);
if (response.success && response.data) {
return { success: true, data: response.data };
}
return {
success: false,
error: response.error || "Erro ao buscar horários disponíveis",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
async listAppointments(
params?: ListAppointmentsParams
): Promise<ApiResponse<Appointment[]>> {
try {
const queryParams: Record<string, string> = {};
if (params?.select) queryParams.select = params.select;
if (params?.doctor_id)
queryParams["doctor_id"] = `eq.${params.doctor_id}`;
if (params?.patient_id)
queryParams["patient_id"] = `eq.${params.patient_id}`;
if (params?.status) queryParams["status"] = `eq.${params.status}`;
if (params?.scheduled_at)
queryParams["scheduled_at"] = params.scheduled_at;
if (params?.order) queryParams.order = params.order;
if (params?.limit) queryParams.limit = String(params.limit);
if (params?.offset) queryParams.offset = String(params.offset);
if (!queryParams.select) queryParams.select = "*";
if (!queryParams.order) queryParams.order = "scheduled_at.desc";
if (!queryParams.limit) queryParams.limit = "100";
const response = await http.get<Appointment[]>(ENDPOINTS.APPOINTMENTS, {
params: queryParams,
});
if (response.success && response.data) {
return {
success: true,
data: Array.isArray(response.data) ? response.data : [response.data],
};
}
return {
success: false,
error: response.error || "Erro ao listar agendamentos",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
async createAppointment(
data: CreateAppointmentInput
): Promise<ApiResponse<Appointment>> {
try {
// Pegar ID do usuário autenticado
const user = authService.getStoredUser();
if (!user?.id) {
return {
success: false,
error: "Usuário não autenticado",
};
}
const payload = {
...data,
created_by: user.id,
};
const response = await http.post<Appointment>(
ENDPOINTS.APPOINTMENTS,
payload,
{
headers: { Prefer: "return=representation" },
}
);
if (response.success && response.data) {
const appointment = Array.isArray(response.data)
? response.data[0]
: response.data;
return {
success: true,
data: appointment,
message: "Agendamento criado com sucesso",
};
}
return {
success: false,
error: response.error || "Erro ao criar agendamento",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
async getAppointmentById(id: string): Promise<ApiResponse<Appointment>> {
try {
const response = await http.get<Appointment[]>(
`${ENDPOINTS.APPOINTMENTS}?id=eq.${id}&select=*`
);
if (response.success && response.data) {
const list = Array.isArray(response.data)
? response.data
: [response.data];
if (list.length > 0) return { success: true, data: list[0] };
}
return { success: false, error: "Agendamento não encontrado" };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
async updateAppointment(
id: string,
updates: UpdateAppointmentInput
): Promise<ApiResponse<Appointment>> {
try {
const response = await http.patch<Appointment>(
`${ENDPOINTS.APPOINTMENTS}?id=eq.${id}`,
updates,
{
headers: { Prefer: "return=representation" },
}
);
if (response.success && response.data) {
const appointment = Array.isArray(response.data)
? response.data[0]
: response.data;
return {
success: true,
data: appointment,
message: "Agendamento atualizado com sucesso",
};
}
return {
success: false,
error: response.error || "Erro ao atualizar agendamento",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
async deleteAppointment(id: string): Promise<ApiResponse<void>> {
try {
const response = await http.delete<void>(
`${ENDPOINTS.APPOINTMENTS}?id=eq.${id}`
);
if (response.success)
return {
success: true,
data: undefined,
message: "Agendamento deletado com sucesso",
};
return {
success: false,
error: response.error || "Erro ao deletar agendamento",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
};
}
}
// Conveniências
confirmAppointment(id: string) {
return this.updateAppointment(id, { status: "confirmed" });
}
checkInAppointment(id: string) {
return this.updateAppointment(id, {
status: "checked_in",
checked_in_at: new Date().toISOString(),
});
}
startAppointment(id: string) {
return this.updateAppointment(id, { status: "in_progress" });
}
completeAppointment(id: string, notes?: string) {
return this.updateAppointment(id, {
status: "completed",
completed_at: new Date().toISOString(),
notes,
});
}
cancelAppointment(id: string, reason?: string) {
return this.updateAppointment(id, {
status: "cancelled",
cancelled_at: new Date().toISOString(),
cancellation_reason: reason,
});
}
markAsNoShow(id: string) {
return this.updateAppointment(id, { status: "no_show" });
}
listDoctorAppointments(
doctorId: string,
params?: Omit<ListAppointmentsParams, "doctor_id">
) {
return this.listAppointments({ ...params, doctor_id: doctorId });
}
listPatientAppointments(
patientId: string,
params?: Omit<ListAppointmentsParams, "patient_id">
) {
return this.listAppointments({ ...params, patient_id: patientId });
}
listTodayAppointments(doctorId: string) {
const today = new Date().toISOString().split("T")[0];
return this.listAppointments({
doctor_id: doctorId,
scheduled_at: `gte.${today}T00:00:00`,
order: "scheduled_at.asc",
});
}
listUpcomingPatientAppointments(patientId: string) {
const now = new Date().toISOString();
return this.listAppointments({
patient_id: patientId,
scheduled_at: `gte.${now}`,
order: "scheduled_at.asc",
limit: 10,
});
}
}
export const appointmentService = new AppointmentService();
export default appointmentService;

View File

@ -0,0 +1,107 @@
/**
* Serviço de Agendamentos
*/
import { apiClient } from "../api/client";
import type {
Appointment,
CreateAppointmentInput,
UpdateAppointmentInput,
AppointmentFilters,
GetAvailableSlotsInput,
GetAvailableSlotsResponse,
} from "./types";
class AppointmentService {
private readonly basePath = "/appointments";
/**
* Busca horários disponíveis de um médico
*/
async getAvailableSlots(
data: GetAvailableSlotsInput
): Promise<GetAvailableSlotsResponse> {
const response = await apiClient.post<GetAvailableSlotsResponse>(
"/get-available-slots",
data
);
return response.data;
}
/**
* Lista agendamentos com filtros opcionais
*/
async list(filters?: AppointmentFilters): Promise<Appointment[]> {
const params: Record<string, string> = {};
if (filters?.doctor_id) {
params["doctor_id"] = `eq.${filters.doctor_id}`;
}
if (filters?.patient_id) {
params["patient_id"] = `eq.${filters.patient_id}`;
}
if (filters?.status) {
params["status"] = `eq.${filters.status}`;
}
if (filters?.scheduled_at) {
params["scheduled_at"] = filters.scheduled_at;
}
if (filters?.limit) {
params["limit"] = filters.limit.toString();
}
if (filters?.offset) {
params["offset"] = filters.offset.toString();
}
if (filters?.order) {
params["order"] = filters.order;
}
const response = await apiClient.get<Appointment[]>(this.basePath, {
params,
});
return response.data;
}
/**
* Busca agendamento por ID
*/
async getById(id: string): Promise<Appointment> {
const response = await apiClient.get<Appointment>(`${this.basePath}/${id}`);
return response.data;
}
/**
* Cria novo agendamento
* Nota: order_number é gerado automaticamente (APT-YYYY-NNNN)
*/
async create(data: CreateAppointmentInput): Promise<Appointment> {
const response = await apiClient.post<Appointment>(this.basePath, data);
return response.data;
}
/**
* Atualiza agendamento existente
*/
async update(id: string, data: UpdateAppointmentInput): Promise<Appointment> {
const response = await apiClient.patch<Appointment>(
`${this.basePath}/${id}`,
data
);
return response.data;
}
/**
* Deleta agendamento
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`${this.basePath}/${id}`);
}
}
export const appointmentService = new AppointmentService();

View File

@ -0,0 +1,88 @@
/**
* Tipos para o módulo de Agendamentos
*/
export type AppointmentType = "presencial" | "telemedicina";
export type AppointmentStatus =
| "requested"
| "confirmed"
| "checked_in"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export interface Appointment {
id: string;
order_number: string; // APT-YYYY-NNNN (auto-gerado)
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes: number;
appointment_type: AppointmentType;
status: AppointmentStatus;
chief_complaint: string | null;
patient_notes: string | null;
notes: string | null;
insurance_provider: string | null;
checked_in_at: string | null;
completed_at: string | null;
cancelled_at: string | null;
cancellation_reason: string | null;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
}
export interface CreateAppointmentInput {
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes?: number;
appointment_type?: AppointmentType;
chief_complaint?: string;
patient_notes?: string;
insurance_provider?: string;
}
export interface UpdateAppointmentInput {
scheduled_at?: string;
duration_minutes?: number;
status?: AppointmentStatus;
chief_complaint?: string;
notes?: string;
patient_notes?: string;
insurance_provider?: string;
checked_in_at?: string;
completed_at?: string;
cancelled_at?: string;
cancellation_reason?: string;
}
export interface AppointmentFilters {
doctor_id?: string;
patient_id?: string;
status?: AppointmentStatus;
scheduled_at?: string; // Use com operadores: gte.2025-10-10
limit?: number;
offset?: number;
order?: string;
}
export interface GetAvailableSlotsInput {
doctor_id: string;
start_date: string;
end_date: string;
appointment_type?: AppointmentType;
}
export interface TimeSlot {
datetime: string;
available: boolean;
}
export interface GetAvailableSlotsResponse {
slots: TimeSlot[];
}

View File

@ -0,0 +1,59 @@
/**
* Serviço de Atribuições de Pacientes (Frontend)
*/
import { apiClient } from "../api/client";
import type {
PatientAssignment,
CreateAssignmentInput,
AssignmentFilters,
} from "./types";
class AssignmentService {
/**
* Lista todas as atribuições de pacientes
*/
async list(filters?: AssignmentFilters): Promise<PatientAssignment[]> {
try {
// Monta query params para filtros
const params = new URLSearchParams();
if (filters?.patient_id) {
params.append("patient_id", filters.patient_id);
}
if (filters?.user_id) {
params.append("user_id", filters.user_id);
}
if (filters?.role) {
params.append("role", filters.role);
}
const queryString = params.toString();
const url = queryString ? `/assignments?${queryString}` : "/assignments";
const response = await apiClient.get<PatientAssignment[]>(url);
return response.data;
} catch (error) {
console.error("Erro ao listar atribuições:", error);
throw error;
}
}
/**
* Cria nova atribuição de paciente
*/
async create(data: CreateAssignmentInput): Promise<PatientAssignment> {
try {
const response = await apiClient.post<PatientAssignment>(
"/assignments",
data
);
return response.data;
} catch (error) {
console.error("Erro ao criar atribuição:", error);
throw error;
}
}
}
export const assignmentService = new AssignmentService();

View File

@ -0,0 +1,26 @@
/**
* Types para Atribuições de Pacientes
*/
export type AssignmentRole = "medico" | "enfermeiro";
export interface PatientAssignment {
id?: string;
patient_id: string;
user_id: string;
role: AssignmentRole;
created_at?: string;
created_by?: string;
}
export interface CreateAssignmentInput {
patient_id: string;
user_id: string;
role: AssignmentRole;
}
export interface AssignmentFilters {
patient_id?: string;
user_id?: string;
role?: AssignmentRole;
}

View File

@ -0,0 +1,141 @@
/**
* Serviço de Autenticação (Frontend)
* Chama as Netlify Functions, não o Supabase direto
*/
import { apiClient } from "../api/client";
import { API_CONFIG } from "../api/config";
import type {
LoginInput,
LoginResponse,
AuthUser,
RefreshTokenResponse,
} from "./types";
class AuthService {
/**
* Faz login com email e senha
*/
async login(credentials: LoginInput): Promise<LoginResponse> {
try {
const response = await apiClient.post<LoginResponse>(
"/auth-login",
credentials
);
// Salva tokens e user no localStorage
if (response.data.access_token) {
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
response.data.access_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
response.data.refresh_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.USER,
JSON.stringify(response.data.user)
);
}
return response.data;
} catch (error) {
console.error("Erro no login:", error);
throw error;
}
}
/**
* Faz logout (invalida sessão no servidor e limpa localStorage)
*/
async logout(): Promise<void> {
try {
// Chama API para invalidar sessão no servidor
await apiClient.post("/auth-logout");
} catch (error) {
console.error("Erro ao invalidar sessão no servidor:", error);
// Continua mesmo com erro, para garantir limpeza local
} finally {
// Sempre limpa o localStorage
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
}
}
/**
* Verifica se usuário está autenticado
*/
isAuthenticated(): boolean {
return !!localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
}
/**
* Retorna o usuário atual do localStorage
*/
getCurrentUser(): AuthUser | null {
const userStr = localStorage.getItem(API_CONFIG.STORAGE_KEYS.USER);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
/**
* Retorna o access token
*/
getAccessToken(): string | null {
return localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
}
/**
* Renova o access token usando o refresh token
*/
async refreshToken(): Promise<RefreshTokenResponse> {
try {
const refreshToken = localStorage.getItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN
);
if (!refreshToken) {
throw new Error("Refresh token não encontrado");
}
const response = await apiClient.post<RefreshTokenResponse>(
"/auth-refresh",
{
refresh_token: refreshToken,
}
);
// Atualiza tokens e user no localStorage
if (response.data.access_token) {
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
response.data.access_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
response.data.refresh_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.USER,
JSON.stringify(response.data.user)
);
}
return response.data;
} catch (error) {
console.error("Erro ao renovar token:", error);
// Se falhar, limpa tudo e força novo login
this.logout();
throw error;
}
}
}
export const authService = new AuthService();

View File

@ -0,0 +1,42 @@
/**
* Types para Autenticação
*/
export interface LoginInput {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}
export interface AuthUser {
id: string;
email: string;
email_confirmed_at: string;
}
export interface RefreshTokenInput {
refresh_token: string;
}
export interface RefreshTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}

View File

@ -1,8 +0,0 @@
// Configurações relacionadas à autenticação / segurança no cliente.
// Centraliza tunables para facilitar ajuste e documentação.
export const AUTH_SECURITY_CONFIG = {
MAX_401_BEFORE_FORCED_LOGOUT: 3,
};
export type AuthSecurityConfig = typeof AUTH_SECURITY_CONFIG;

View File

@ -1,339 +0,0 @@
import api from "./api";
import { ApiResponse } from "./http";
import { SUPABASE_URL, SUPABASE_ANON_KEY } from "./supabaseConfig";
import tokenStore from "./tokenStore";
import { logger } from "./logger";
export interface LoginCredentials {
email: string;
password: string;
}
export interface AuthUser {
id: string;
email: string;
email_confirmed_at: string;
created_at: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: AuthUser;
}
// Novo payload user-info completo
export interface UserInfoUser {
id?: string;
email?: string;
email_confirmed_at?: string | null;
created_at?: string;
last_sign_in_at?: string | null;
}
export interface UserInfoProfile {
id?: string;
full_name?: string | null;
email?: string | null;
phone?: string | null;
avatar_url?: string | null;
disabled?: boolean;
created_at?: string;
updated_at?: string;
}
export interface UserInfoPermissions {
isAdmin?: boolean;
isManager?: boolean;
isDoctor?: boolean;
isSecretary?: boolean;
isAdminOrManager?: boolean;
}
export interface UserInfoFullResponse {
user?: UserInfoUser;
profile?: UserInfoProfile | null;
roles?: string[];
permissions?: UserInfoPermissions;
}
// Usa ApiResponse unificado de http.ts
class AuthService {
// Chaves legacy usadas apenas para migração/limpeza; armazenamento corrente em tokenStore
private tokenKey = "authToken";
private userKey = "authUser";
private refreshTokenKey = "refreshToken";
async login(
credentials: LoginCredentials
): Promise<ApiResponse<LoginResponse>> {
try {
logger.debug("login attempt", { email: credentials.email });
logger.debug("login endpoint", {
url: "/auth/v1/token?grant_type=password",
});
const response = await api.post("/auth/v1/token", credentials, {
params: { grant_type: "password" },
});
logger.info("login success", { status: response.status });
const loginData: LoginResponse = response.data;
// Persistir em tokenStore (access em memória, refresh em sessionStorage)
tokenStore.setTokens(loginData.access_token, loginData.refresh_token);
tokenStore.setUser(loginData.user);
logger.debug("tokens stored");
return { success: true, data: loginData };
} catch (error: unknown) {
logger.error("login error", { error: (error as Error)?.message });
// Extrair mensagem de erro detalhada
let errorMessage = "Erro ao fazer login";
if (error instanceof Error && "response" in error) {
const axiosError = error as {
response?: {
data?: { error_code?: string; msg?: string; message?: string };
};
};
const errorData = axiosError.response?.data;
// Verificar se é erro de email não confirmado
if (errorData?.error_code === "email_not_confirmed") {
errorMessage =
"Email não confirmado. Verifique sua caixa de entrada ou configure o Supabase para não exigir confirmação.";
} else if (errorData?.msg) {
errorMessage = errorData.msg;
} else if (errorData?.message) {
errorMessage = errorData.message;
}
}
return {
success: false,
error: errorMessage,
};
}
}
async getUserInfo(): Promise<ApiResponse<UserInfoFullResponse>> {
try {
// Buscar dados básicos do usuário
const userResponse = await api.get("/auth/v1/user");
const userData = userResponse.data;
// Buscar informações adicionais (profile, roles, permissions)
// Se você tiver esses endpoints específicos, adicione aqui
// Por enquanto, vamos tentar buscar do endpoint customizado
let fullData: UserInfoFullResponse = {
user: {
id: userData.id,
email: userData.email,
email_confirmed_at: userData.email_confirmed_at,
created_at: userData.created_at,
last_sign_in_at: userData.last_sign_in_at,
},
roles: [],
permissions: {},
};
// Tentar buscar dados completos do endpoint customizado
try {
const fullResponse = await api.get("/functions/v1/user-info");
if (fullResponse.data) {
fullData = fullResponse.data as UserInfoFullResponse;
}
} catch {
logger.warn(
"user-info edge function indisponível, usando dados básicos"
);
}
return { success: true, data: fullData };
} catch (error: unknown) {
logger.error("erro ao obter user-info", {
error: (error as Error)?.message,
});
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response
?.data?.message || "Erro ao obter user-info"
: "Erro ao obter user-info";
return { success: false, error: errorMessage };
}
}
async logout(): Promise<ApiResponse<void>> {
try {
const resp = await api.post("/auth/v1/logout");
// Especificação indica 204 No Content; não depende do corpo
if (resp.status !== 204 && resp.status !== 200) {
// Ainda assim vamos limpar local, mas registrar
console.warn("Status inesperado no logout:", resp.status);
}
} catch (error: unknown) {
// 401 => token já inválido / expirado: tratamos como sucesso resiliente
const err = error as { response?: { status?: number } };
if (err?.response?.status === 401) {
logger.info("logout 401 token inválido/expirado (tratado)");
} else {
logger.warn("erro logout servidor", {
error: (error as Error)?.message,
});
}
} finally {
this.clearLocalAuth();
// snapshot opcional para debug
try {
logger.debug("snapshot pós-logout");
} catch {
/* ignore */
}
}
return { success: true };
}
async getCurrentUser(): Promise<ApiResponse<AuthUser>> {
try {
const response = await api.get("/auth/v1/user");
return { success: true, data: response.data };
} catch (error: unknown) {
logger.error("erro ao obter usuário atual", {
error: (error as Error)?.message,
});
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response
?.data?.message || "Erro ao obter dados do usuário"
: "Erro ao obter dados do usuário";
return {
success: false,
error: errorMessage,
};
}
}
// Alias mais semântico solicitado (todo 39) para consultar /auth/v1/user
async getCurrentAuthUser(): Promise<ApiResponse<AuthUser>> {
return this.getCurrentUser();
}
getStoredToken(): string | null {
return tokenStore.getAccessToken();
}
getStoredUser(): AuthUser | null {
return tokenStore.getUser<AuthUser>();
}
isAuthenticated(): boolean {
return !!this.getStoredToken();
}
clearLocalAuth(): void {
tokenStore.clear();
// Limpeza defensiva de resíduos legacy
try {
localStorage.removeItem(this.tokenKey);
} catch {
/* ignore */
}
try {
localStorage.removeItem(this.userKey);
} catch {
/* ignore */
}
try {
localStorage.removeItem(this.refreshTokenKey);
} catch {
/* ignore */
}
}
async refreshToken(): Promise<ApiResponse<LoginResponse>> {
try {
const refreshToken = tokenStore.getRefreshToken();
if (!refreshToken)
return { success: false, error: "Refresh token não encontrado" };
// Usar fetch direto para garantir apikey + query param explicitamente
const url = `${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
const txt = await res.text();
logger.warn("refresh token falhou", { status: res.status, body: txt });
this.clearLocalAuth();
return { success: false, error: "Erro ao renovar token" };
}
const data = (await res.json()) as LoginResponse;
tokenStore.setTokens(data.access_token, data.refresh_token);
tokenStore.setUser(data.user);
return { success: true, data };
} catch (error: unknown) {
logger.error("erro ao renovar token", {
error: (error as Error)?.message,
});
this.clearLocalAuth();
return { success: false, error: "Erro ao renovar token" };
}
}
// Verificar se o usuário logado é médico (tem registro na tabela doctors)
async checkIsDoctor(
userId: string
): Promise<
ApiResponse<{ isDoctor: boolean; doctorId?: string; doctorData?: unknown }>
> {
try {
logger.debug("verificando se usuário é médico", { userId });
const response = await api.get(`/rest/v1/doctors`, {
params: {
user_id: `eq.${userId}`,
select: "*",
},
});
const doctors = response.data;
const isDoctor = Array.isArray(doctors) && doctors.length > 0;
if (isDoctor) {
logger.debug("usuario é médico", { doctorId: doctors[0].id });
return {
success: true,
data: {
isDoctor: true,
doctorId: doctors[0].id,
doctorData: doctors[0],
},
};
}
logger.debug("usuario não é médico");
return {
success: true,
data: { isDoctor: false },
};
} catch (error: unknown) {
logger.error("erro ao verificar médico", {
error: (error as Error)?.message,
});
return {
success: false,
error: "Erro ao verificar credenciais de médico",
};
}
}
}
export const authService = new AuthService();
export default authService;

View File

@ -0,0 +1,61 @@
/**
* Availability Service
*
* Serviço para gerenciamento de disponibilidade dos médicos
*/
import { apiClient } from "../api/client";
import {
DoctorAvailability,
ListAvailabilityFilters,
CreateAvailabilityInput,
UpdateAvailabilityInput,
} from "./types";
class AvailabilityService {
private readonly basePath = "/doctor-availability";
/**
* Lista as disponibilidades dos médicos
*/
async list(filters?: ListAvailabilityFilters): Promise<DoctorAvailability[]> {
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
params: filters,
});
return response.data;
}
/**
* Cria uma nova configuração de disponibilidade
*/
async create(data: CreateAvailabilityInput): Promise<DoctorAvailability> {
const response = await apiClient.post<DoctorAvailability>(
this.basePath,
data
);
return response.data;
}
/**
* Atualiza uma configuração de disponibilidade
*/
async update(
id: string,
data: UpdateAvailabilityInput
): Promise<DoctorAvailability> {
const response = await apiClient.patch<DoctorAvailability>(
`${this.basePath}/${id}`,
data
);
return response.data;
}
/**
* Remove uma configuração de disponibilidade
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`${this.basePath}/${id}`);
}
}
export const availabilityService = new AvailabilityService();

View File

@ -0,0 +1,74 @@
/**
* Availability Module Types
*
* Tipos para gerenciamento de disponibilidade dos médicos
*/
/**
* Dias da semana
*/
export type Weekday =
| "segunda"
| "terca"
| "quarta"
| "quinta"
| "sexta"
| "sabado"
| "domingo";
/**
* Tipo de atendimento
*/
export type AppointmentType = "presencial" | "telemedicina";
/**
* Interface para disponibilidade de médico
*/
export interface DoctorAvailability {
id?: string;
doctor_id?: string;
weekday?: Weekday;
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number; // Default: 30
appointment_type?: AppointmentType;
active?: boolean; // Default: true
created_at?: string;
updated_at?: string;
created_by?: string;
updated_by?: string | null;
}
/**
* Filtros para listagem de disponibilidades
*/
export interface ListAvailabilityFilters {
select?: string;
doctor_id?: string;
active?: boolean;
}
/**
* Input para criar disponibilidade
*/
export interface CreateAvailabilityInput {
doctor_id: string; // required
weekday: Weekday; // required
start_time: string; // required - Formato: HH:MM:SS (ex: "09:00:00")
end_time: string; // required - Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number; // optional - Default: 30
appointment_type?: AppointmentType; // optional - Default: 'presencial'
active?: boolean; // optional - Default: true
}
/**
* Input para atualizar disponibilidade
*/
export interface UpdateAvailabilityInput {
weekday?: Weekday;
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number;
appointment_type?: AppointmentType;
active?: boolean;
}

View File

@ -1,465 +0,0 @@
/**
* Service para gerenciar disponibilidades dos médicos
* Configuração de horários de trabalho por dia da semana
*/
import { http, ApiResponse } from "./http";
import ENDPOINTS from "./endpoints";
import authService from "./authService";
export type Weekday =
| "segunda"
| "terca"
| "quarta"
| "quinta"
| "sexta"
| "sabado"
| "domingo";
// Tipo que o banco de dados realmente aceita (provavelmente em inglês ou números)
export type WeekdayDB =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
export type AppointmentType = "presencial" | "telemedicina";
export interface DoctorAvailability {
id?: string;
doctor_id?: string;
weekday?: Weekday;
start_time?: string; // "09:00:00"
end_time?: string; // "17:00:00"
slot_minutes?: number; // padrão: 30
appointment_type?: AppointmentType;
active?: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
updated_by?: string | null;
}
export interface CreateAvailabilityInput {
doctor_id: string;
weekday: Weekday;
start_time: string;
end_time: string;
slot_minutes?: number;
appointment_type?: AppointmentType;
active?: boolean;
}
export interface UpdateAvailabilityInput {
weekday?: Weekday;
start_time?: string;
end_time?: string;
slot_minutes?: number;
appointment_type?: AppointmentType;
active?: boolean;
}
export interface ListAvailabilityParams {
select?: string;
doctor_id?: string;
active?: boolean;
}
export const WEEKDAY_MAP = {
0: "domingo",
1: "segunda",
2: "terca",
3: "quarta",
4: "quinta",
5: "sexta",
6: "sabado",
} as const;
export const WEEKDAY_LABELS = {
segunda: "Segunda-feira",
terca: "Terça-feira",
quarta: "Quarta-feira",
quinta: "Quinta-feira",
sexta: "Sexta-feira",
sabado: "Sábado",
domingo: "Domingo",
} as const;
// Mapeamento PT-BR → EN (para o banco de dados)
export const WEEKDAY_PT_TO_EN: Record<Weekday, WeekdayDB> = {
segunda: "monday",
terca: "tuesday",
quarta: "wednesday",
quinta: "thursday",
sexta: "friday",
sabado: "saturday",
domingo: "sunday",
};
// Mapeamento EN → PT-BR (do banco de dados)
export const WEEKDAY_EN_TO_PT: Record<WeekdayDB, Weekday> = {
monday: "segunda",
tuesday: "terca",
wednesday: "quarta",
thursday: "quinta",
friday: "sexta",
saturday: "sabado",
sunday: "domingo",
};
// Converter para formato do banco
export function convertWeekdayToDB(weekday: Weekday): WeekdayDB {
return WEEKDAY_PT_TO_EN[weekday];
}
// Converter do formato do banco
export function convertWeekdayFromDB(weekday: WeekdayDB): Weekday {
return WEEKDAY_EN_TO_PT[weekday];
}
export function getWeekdayName(dayNumber: number): Weekday {
return WEEKDAY_MAP[dayNumber as keyof typeof WEEKDAY_MAP];
}
export function getWeekdayFromDate(date: Date): Weekday {
return getWeekdayName(date.getDay());
}
class AvailabilityService {
async listAvailability(
params?: ListAvailabilityParams
): Promise<ApiResponse<DoctorAvailability[]>> {
try {
const q: Record<string, string> = {};
if (params?.select) q.select = params.select;
if (params?.doctor_id) q["doctor_id"] = `eq.${params.doctor_id}`;
if (params?.active !== undefined) q["active"] = `eq.${params.active}`;
if (!q.select) q.select = "*";
const res = await http.get<DoctorAvailability[]>(
ENDPOINTS.DOCTOR_AVAILABILITY,
{ params: q }
);
if (res.success && res.data) {
const dataArray = Array.isArray(res.data) ? res.data : [res.data];
// Converter weekdays do banco (inglês) para PT-BR
const converted = dataArray.map((item) => {
if (item.weekday) {
return {
...item,
weekday: convertWeekdayFromDB(
item.weekday as WeekdayDB
) as Weekday,
};
}
return item;
});
return {
success: true,
data: converted,
};
}
return {
success: false,
error: res.error || "Erro ao listar disponibilidades",
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Erro desconhecido",
};
}
}
async createAvailability(
data: CreateAvailabilityInput
): Promise<ApiResponse<DoctorAvailability>> {
try {
console.log(
"[AvailabilityService] Criando disponibilidade (PT-BR):",
data
);
// Pegar ID do usuário autenticado
const user = authService.getStoredUser();
if (!user?.id) {
return {
success: false,
error: "Usuário não autenticado",
};
}
// Converter weekday para inglês (formato do banco) e adicionar created_by
const payload = {
...data,
weekday: convertWeekdayToDB(data.weekday),
created_by: user.id,
};
console.log("[AvailabilityService] Payload convertido (EN):", payload);
console.log(
"[AvailabilityService] Endpoint:",
ENDPOINTS.DOCTOR_AVAILABILITY
);
const res = await http.post<DoctorAvailability>(
ENDPOINTS.DOCTOR_AVAILABILITY,
payload,
{ headers: { Prefer: "return=representation" } }
);
console.log("[AvailabilityService] Resposta:", res);
if (res.success && res.data) {
const item = Array.isArray(res.data) ? res.data[0] : res.data;
// Converter weekday de volta para PT-BR antes de retornar
if (item.weekday) {
item.weekday = convertWeekdayFromDB(
item.weekday as WeekdayDB
) as Weekday;
}
return {
success: true,
data: item,
message: "Disponibilidade criada com sucesso",
};
}
return {
success: false,
error: res.error || "Erro ao criar disponibilidade",
};
} catch (e) {
console.error("[AvailabilityService] Exceção:", e);
return {
success: false,
error: e instanceof Error ? e.message : "Erro desconhecido",
};
}
}
async updateAvailability(
id: string,
updates: UpdateAvailabilityInput
): Promise<ApiResponse<DoctorAvailability>> {
try {
// Converter weekday se presente
const payload = updates.weekday
? { ...updates, weekday: convertWeekdayToDB(updates.weekday) }
: updates;
const res = await http.patch<DoctorAvailability>(
`${ENDPOINTS.DOCTOR_AVAILABILITY}?id=eq.${id}`,
payload,
{ headers: { Prefer: "return=representation" } }
);
if (res.success && res.data) {
const item = Array.isArray(res.data) ? res.data[0] : res.data;
// Converter weekday de volta para PT-BR
if (item.weekday) {
item.weekday = convertWeekdayFromDB(
item.weekday as WeekdayDB
) as Weekday;
}
return {
success: true,
data: item,
message: "Disponibilidade atualizada com sucesso",
};
}
return {
success: false,
error: res.error || "Erro ao atualizar disponibilidade",
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Erro desconhecido",
};
}
}
async deleteAvailability(id: string): Promise<ApiResponse<void>> {
try {
const res = await http.delete<void>(
`${ENDPOINTS.DOCTOR_AVAILABILITY}?id=eq.${id}`
);
if (res.success)
return {
success: true,
data: undefined,
message: "Disponibilidade deletada com sucesso",
};
return {
success: false,
error: res.error || "Erro ao deletar disponibilidade",
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Erro desconhecido",
};
}
}
async listDoctorActiveAvailability(doctorId: string) {
return this.listAvailability({ doctor_id: doctorId, active: true });
}
activateAvailability(id: string) {
return this.updateAvailability(id, { active: true });
}
deactivateAvailability(id: string) {
return this.updateAvailability(id, { active: false });
}
async createWeekSchedule(
doctorId: string,
weekdays: Weekday[],
startTime: string,
endTime: string,
slotMinutes = 30,
appointmentType: AppointmentType = "presencial"
) {
const results: DoctorAvailability[] = [];
const errors: string[] = [];
for (const weekday of weekdays) {
const res = await this.createAvailability({
doctor_id: doctorId,
weekday,
start_time: startTime,
end_time: endTime,
slot_minutes: slotMinutes,
appointment_type: appointmentType,
active: true,
});
if (res.success && res.data) results.push(res.data);
else errors.push(`${weekday}: ${res.error}`);
}
if (errors.length)
return {
success: false,
error: `Erros: ${errors.join(", ")}`,
} as ApiResponse<DoctorAvailability[]>;
return {
success: true,
data: results,
message: "Horários da semana criados com sucesso",
} as ApiResponse<DoctorAvailability[]>;
}
async getDoctorAvailabilityForDay(doctorId: string, weekday: Weekday) {
const res = await this.listAvailability({
doctor_id: doctorId,
active: true,
});
if (!res.success || !res.data)
return res as ApiResponse<DoctorAvailability[]>;
return {
success: true,
data: res.data.filter((a) => a.weekday === weekday),
} as ApiResponse<DoctorAvailability[]>;
}
async isDoctorAvailableOnDay(doctorId: string, weekday: Weekday) {
const res = await this.getDoctorAvailabilityForDay(doctorId, weekday);
return !!(res.success && res.data && res.data.length > 0);
}
async getDoctorScheduleSummary(
doctorId: string
): Promise<
ApiResponse<
Record<
Weekday,
Array<{ start: string; end: string; type: AppointmentType }>
>
>
> {
const res = await this.listDoctorActiveAvailability(doctorId);
if (!res.success || !res.data)
return { success: false, error: "Erro ao buscar horários" };
const summary: Record<
Weekday,
Array<{ start: string; end: string; type: AppointmentType }>
> = {
segunda: [],
terca: [],
quarta: [],
quinta: [],
sexta: [],
sabado: [],
domingo: [],
};
res.data.forEach((a) => {
if (a.weekday && a.start_time && a.end_time)
summary[a.weekday].push({
start: a.start_time,
end: a.end_time,
type: a.appointment_type || "presencial",
});
});
return { success: true, data: summary };
}
// Compatibilidade: método utilitário para retornar a disponibilidade semanal
// no formato ApiResponse esperado pelos componentes existentes.
async getAvailability(
doctorId: string
): Promise<
ApiResponse<any>
> {
try {
// Alguns componentes esperam a disponibilidade semanal (com chaves
// domingo/segunda/..). Tentar obter via listAvailability (tabelas
// granular por weekday) e transformar se necessário.
const res = await this.listAvailability({ doctor_id: doctorId });
if (!res.success || !res.data) {
return { success: false, error: res.error || "Nenhuma disponibilidade" };
}
// Se o backend já retornar o objeto semanal (com campos domingo..sabado),
// apenas repassar. Caso contrário, agrupar os registros por weekday
// para manter compatibilidade com componentes que esperam esse formato.
const first = res.data[0];
const looksLikeWeekly = first && (first as any).domingo !== undefined;
if (looksLikeWeekly) {
return { success: true, data: res.data };
}
// Agrupar registros por weekday (convertido para PT-BR em listAvailability)
const weekly: Record<string, any> = {
domingo: { ativo: false, horarios: [] },
segunda: { ativo: false, horarios: [] },
terca: { ativo: false, horarios: [] },
quarta: { ativo: false, horarios: [] },
quinta: { ativo: false, horarios: [] },
sexta: { ativo: false, horarios: [] },
sabado: { ativo: false, horarios: [] },
};
res.data.forEach((item: any) => {
const wd = item.weekday as any; // segunda/terca/...
if (!wd) return;
// Converter cada registro para formato { ativo, horarios: [{inicio,fim,ativo}] }
const entry = {
ativo: item.active ?? item.ativo ?? true,
horarios: [
{
inicio: item.start_time || item.inicio || "",
fim: item.end_time || item.fim || "",
ativo: item.active ?? item.ativo ?? true,
},
],
};
weekly[wd] = entry;
});
return { success: true, data: [weekly] };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Erro desconhecido" };
}
}
}
export const availabilityService = new AvailabilityService();
export default availabilityService;

Some files were not shown because too many files have changed in this diff Show More