Atualizando
This commit is contained in:
parent
272f81f44b
commit
376e344506
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
163
MEDICONNECT 2/netlify/functions/appointments.ts
Normal file
163
MEDICONNECT 2/netlify/functions/appointments.ts
Normal 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" }),
|
||||
};
|
||||
}
|
||||
};
|
||||
153
MEDICONNECT 2/netlify/functions/assignments.ts
Normal file
153
MEDICONNECT 2/netlify/functions/assignments.ts
Normal 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" }),
|
||||
};
|
||||
};
|
||||
95
MEDICONNECT 2/netlify/functions/auth-login.ts
Normal file
95
MEDICONNECT 2/netlify/functions/auth-login.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
90
MEDICONNECT 2/netlify/functions/auth-logout.ts
Normal file
90
MEDICONNECT 2/netlify/functions/auth-logout.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
89
MEDICONNECT 2/netlify/functions/auth-magic-link.ts
Normal file
89
MEDICONNECT 2/netlify/functions/auth-magic-link.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
87
MEDICONNECT 2/netlify/functions/auth-refresh.ts
Normal file
87
MEDICONNECT 2/netlify/functions/auth-refresh.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
77
MEDICONNECT 2/netlify/functions/auth-user.ts
Normal file
77
MEDICONNECT 2/netlify/functions/auth-user.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
94
MEDICONNECT 2/netlify/functions/avatars-delete.ts
Normal file
94
MEDICONNECT 2/netlify/functions/avatars-delete.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
145
MEDICONNECT 2/netlify/functions/avatars-upload.ts
Normal file
145
MEDICONNECT 2/netlify/functions/avatars-upload.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
100
MEDICONNECT 2/netlify/functions/create-doctor.ts
Normal file
100
MEDICONNECT 2/netlify/functions/create-doctor.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
102
MEDICONNECT 2/netlify/functions/create-patient.ts
Normal file
102
MEDICONNECT 2/netlify/functions/create-patient.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
120
MEDICONNECT 2/netlify/functions/create-user.ts
Normal file
120
MEDICONNECT 2/netlify/functions/create-user.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
217
MEDICONNECT 2/netlify/functions/doctor-availability.ts
Normal file
217
MEDICONNECT 2/netlify/functions/doctor-availability.ts
Normal 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": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
169
MEDICONNECT 2/netlify/functions/doctor-exceptions.ts
Normal file
169
MEDICONNECT 2/netlify/functions/doctor-exceptions.ts
Normal 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": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
237
MEDICONNECT 2/netlify/functions/doctors.ts
Normal file
237
MEDICONNECT 2/netlify/functions/doctors.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
95
MEDICONNECT 2/netlify/functions/get-available-slots.ts
Normal file
95
MEDICONNECT 2/netlify/functions/get-available-slots.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
226
MEDICONNECT 2/netlify/functions/patients.ts
Normal file
226
MEDICONNECT 2/netlify/functions/patients.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
155
MEDICONNECT 2/netlify/functions/profiles.ts
Normal file
155
MEDICONNECT 2/netlify/functions/profiles.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
197
MEDICONNECT 2/netlify/functions/reports.ts
Normal file
197
MEDICONNECT 2/netlify/functions/reports.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
93
MEDICONNECT 2/netlify/functions/send-sms.ts
Normal file
93
MEDICONNECT 2/netlify/functions/send-sms.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
93
MEDICONNECT 2/netlify/functions/user-info-by-id.ts
Normal file
93
MEDICONNECT 2/netlify/functions/user-info-by-id.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
81
MEDICONNECT 2/netlify/functions/user-info.ts
Normal file
81
MEDICONNECT 2/netlify/functions/user-info.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
161
MEDICONNECT 2/netlify/functions/user-roles.ts
Normal file
161
MEDICONNECT 2/netlify/functions/user-roles.ts
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
7142
MEDICONNECT 2/pnpm-lock.yaml
generated
7142
MEDICONNECT 2/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
92
MEDICONNECT 2/src/components/AgendamentoConsultaSimples.tsx
Normal file
92
MEDICONNECT 2/src/components/AgendamentoConsultaSimples.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
418
MEDICONNECT 2/src/components/BookAppointment.tsx
Normal file
418
MEDICONNECT 2/src/components/BookAppointment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
84
MEDICONNECT 2/src/components/secretaria/AgendaSection.tsx
Normal file
84
MEDICONNECT 2/src/components/secretaria/AgendaSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
MEDICONNECT 2/src/components/secretaria/ConsultasSection.tsx
Normal file
296
MEDICONNECT 2/src/components/secretaria/ConsultasSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
MEDICONNECT 2/src/components/secretaria/RelatoriosSection.tsx
Normal file
181
MEDICONNECT 2/src/components/secretaria/RelatoriosSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
561
MEDICONNECT 2/src/components/secretaria/SecretaryDoctorList.tsx
Normal file
561
MEDICONNECT 2/src/components/secretaria/SecretaryDoctorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
513
MEDICONNECT 2/src/components/secretaria/SecretaryPatientList.tsx
Normal file
513
MEDICONNECT 2/src/components/secretaria/SecretaryPatientList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
MEDICONNECT 2/src/components/secretaria/SecretaryReportList.tsx
Normal file
247
MEDICONNECT 2/src/components/secretaria/SecretaryReportList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
MEDICONNECT 2/src/components/secretaria/index.ts
Normal file
5
MEDICONNECT 2/src/components/secretaria/index.ts
Normal 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";
|
||||
158
MEDICONNECT 2/src/components/ui/Avatar.tsx
Normal file
158
MEDICONNECT 2/src/components/ui/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
MEDICONNECT 2/src/components/ui/AvatarUpload.tsx
Normal file
218
MEDICONNECT 2/src/components/ui/AvatarUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
]
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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;
|
||||
@ -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!");
|
||||
|
||||
481
MEDICONNECT 2/src/pages/AvatarShowcase.tsx
Normal file
481
MEDICONNECT 2/src/pages/AvatarShowcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
55
MEDICONNECT 2/src/pages/ClearCache.tsx
Normal file
55
MEDICONNECT 2/src/pages/ClearCache.tsx
Normal 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;
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
3544
MEDICONNECT 2/src/pages/PainelSecretaria.backup.tsx
Normal file
3544
MEDICONNECT 2/src/pages/PainelSecretaria.backup.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
517
MEDICONNECT 2/src/pages/PerfilMedico.tsx
Normal file
517
MEDICONNECT 2/src/pages/PerfilMedico.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
597
MEDICONNECT 2/src/pages/PerfilPaciente.tsx
Normal file
597
MEDICONNECT 2/src/pages/PerfilPaciente.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = () => {
|
||||
← 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>
|
||||
|
||||
@ -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;
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
200
MEDICONNECT 2/src/services/api/client.ts
Normal file
200
MEDICONNECT 2/src/services/api/client.ts
Normal 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();
|
||||
26
MEDICONNECT 2/src/services/api/config.ts
Normal file
26
MEDICONNECT 2/src/services/api/config.ts
Normal 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;
|
||||
@ -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;
|
||||
107
MEDICONNECT 2/src/services/appointments/appointmentService.ts
Normal file
107
MEDICONNECT 2/src/services/appointments/appointmentService.ts
Normal 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();
|
||||
88
MEDICONNECT 2/src/services/appointments/types.ts
Normal file
88
MEDICONNECT 2/src/services/appointments/types.ts
Normal 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[];
|
||||
}
|
||||
59
MEDICONNECT 2/src/services/assignments/assignmentService.ts
Normal file
59
MEDICONNECT 2/src/services/assignments/assignmentService.ts
Normal 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();
|
||||
26
MEDICONNECT 2/src/services/assignments/types.ts
Normal file
26
MEDICONNECT 2/src/services/assignments/types.ts
Normal 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;
|
||||
}
|
||||
141
MEDICONNECT 2/src/services/auth/authService.ts
Normal file
141
MEDICONNECT 2/src/services/auth/authService.ts
Normal 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();
|
||||
42
MEDICONNECT 2/src/services/auth/types.ts
Normal file
42
MEDICONNECT 2/src/services/auth/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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();
|
||||
74
MEDICONNECT 2/src/services/availability/types.ts
Normal file
74
MEDICONNECT 2/src/services/availability/types.ts
Normal 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;
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user