Atualizando
This commit is contained in:
parent
272f81f44b
commit
376e344506
@ -1,33 +1,52 @@
|
|||||||
# Exemplo de configuração de ambiente para MEDICONNECT (Vite)
|
# ⚠️ ESTE ARQUIVO É APENAS UM EXEMPLO
|
||||||
# Renomeie este arquivo para `.env` ou `.env.local` e ajuste os valores.
|
# Renomeie para `.env` e configure as variáveis necessárias
|
||||||
# NUNCA comite credenciais reais.
|
# 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
|
# Supabase - OBRIGATÓRIAS
|
||||||
# VITE_SUPABASE_SERVICE_ROLE=NAO_COLOQUE_AQUI (NUNCA exponha service role no front)
|
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)
|
# MongoDB - OPCIONAL (se você usa)
|
||||||
# Usado apenas se você mantiver um usuário técnico para chamadas server-like.
|
MONGODB_URI=mongodb+srv://usuario:senha@cluster.mongodb.net/database
|
||||||
VITE_SERVICE_EMAIL=
|
|
||||||
VITE_SERVICE_PASSWORD=
|
|
||||||
|
|
||||||
# Ajustes de UI / Feature flags (exemplos futuros)
|
# SMS API - OPCIONAL (se você usa envio de SMS)
|
||||||
# VITE_FEATURE_CONSULTAS_NOVA_TABELA=true
|
SMS_API_KEY=sua-chave-sms-aqui
|
||||||
|
|
||||||
# Ambiente (dev | staging | prod)
|
# ===========================================
|
||||||
VITE_APP_ENV=dev
|
# NOTAS IMPORTANTES
|
||||||
|
# ===========================================
|
||||||
# URL base da API (se diferente do Supabase REST) opcional
|
#
|
||||||
VITE_API_BASE_URL=
|
# 1. DESENVOLVIMENTO LOCAL:
|
||||||
|
# - As Netlify Functions pegam variáveis do Netlify Dev
|
||||||
# Ativar mocks locais (false/true)
|
# - Você pode criar um .env na raiz, mas não é obrigatório
|
||||||
VITE_ENABLE_MOCKS=false
|
#
|
||||||
|
# 2. PRODUÇÃO (Netlify):
|
||||||
# Versão / build meta (pode ser injetado no CI)
|
# ⚠️ OBRIGATÓRIO: Configure em Site Settings → Environment Variables
|
||||||
VITE_APP_VERSION=0.0.0
|
# - SUPABASE_URL
|
||||||
VITE_BUILD_TIME=
|
# - 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
|
## 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
|
## 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).
|
#### **Login com Email e Senha**
|
||||||
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).
|
|
||||||
|
|
||||||
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.
|
- **Endpoint**: `POST /auth/v1/otp`
|
||||||
- RLS controla acesso por `auth.uid()` – fluxo permanece coerente.
|
- **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 {
|
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||||
id: string;
|
const SUPABASE_ANON_KEY =
|
||||||
pacienteId: string;
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||||
medicoId: string;
|
|
||||||
dataHora: string;
|
|
||||||
status: string;
|
|
||||||
tipo?: string;
|
|
||||||
motivo?: string;
|
|
||||||
observacoes?: string;
|
|
||||||
valorPago?: number;
|
|
||||||
formaPagamento?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store em memória (temporário - em produção use Supabase ou outro DB)
|
export const handler: Handler = async (event: HandlerEvent) => {
|
||||||
const consultas: Consulta[] = [];
|
|
||||||
|
|
||||||
const handler: Handler = async (
|
|
||||||
event: HandlerEvent,
|
|
||||||
context: HandlerContext
|
|
||||||
) => {
|
|
||||||
const headers = {
|
const headers = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey",
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (event.httpMethod === "OPTIONS") {
|
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 {
|
try {
|
||||||
// LIST - GET /consultas
|
const authHeader =
|
||||||
if (method === "GET" && !path) {
|
event.headers.authorization || event.headers.Authorization;
|
||||||
const queryParams = event.queryStringParameters || {};
|
if (!authHeader) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 401,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(resultado),
|
body: JSON.stringify({ error: "Token n<>o fornecido" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET BY ID - GET /consultas/:id
|
const pathParts = event.path.split("/");
|
||||||
if (method === "GET" && path.match(/^\/[^/]+$/)) {
|
const appointmentId =
|
||||||
const id = path.substring(1);
|
pathParts[pathParts.length - 1] !== "consultas"
|
||||||
const consulta = consultas.find((c) => c.id === id);
|
? pathParts[pathParts.length - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!consulta) {
|
if (event.httpMethod === "GET") {
|
||||||
return {
|
let url = `${SUPABASE_URL}/rest/v1/appointments`;
|
||||||
statusCode: 404,
|
if (appointmentId && appointmentId !== "consultas") {
|
||||||
headers,
|
url += `?id=eq.${appointmentId}&select=*`;
|
||||||
body: JSON.stringify({ error: "Consulta não encontrada" }),
|
} 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 {
|
return {
|
||||||
statusCode: 200,
|
statusCode: response.status,
|
||||||
headers,
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(consulta),
|
body: JSON.stringify(data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE - POST /consultas
|
if (event.httpMethod === "POST") {
|
||||||
if (method === "POST" && !path) {
|
|
||||||
const body = JSON.parse(event.body || "{}");
|
const body = JSON.parse(event.body || "{}");
|
||||||
const novaConsulta: Consulta = {
|
if (!body.patient_id || !body.doctor_id || !body.scheduled_at) {
|
||||||
id: crypto.randomUUID(),
|
return {
|
||||||
pacienteId: body.pacienteId,
|
statusCode: 400,
|
||||||
medicoId: body.medicoId,
|
headers,
|
||||||
dataHora: body.dataHora,
|
body: JSON.stringify({
|
||||||
status: body.status || "agendada",
|
error: "Campos obrigat<61>rios: patient_id, doctor_id, scheduled_at",
|
||||||
tipo: body.tipo,
|
}),
|
||||||
motivo: body.motivo,
|
};
|
||||||
observacoes: body.observacoes,
|
}
|
||||||
valorPago: body.valorPago,
|
const response = await fetch(`${SUPABASE_URL}/rest/v1/appointments`, {
|
||||||
formaPagamento: body.formaPagamento,
|
method: "POST",
|
||||||
created_at: new Date().toISOString(),
|
headers: {
|
||||||
updated_at: new Date().toISOString(),
|
apikey: SUPABASE_ANON_KEY,
|
||||||
};
|
Authorization: authHeader,
|
||||||
|
"Content-Type": "application/json",
|
||||||
consultas.push(novaConsulta);
|
Prefer: "return=representation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
let data = await response.json();
|
||||||
|
if (Array.isArray(data) && data.length > 0) data = data[0];
|
||||||
return {
|
return {
|
||||||
statusCode: 201,
|
statusCode: response.status,
|
||||||
headers,
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(novaConsulta),
|
body: JSON.stringify(data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATE - PATCH /consultas/:id
|
if (event.httpMethod === "PATCH") {
|
||||||
if ((method === "PATCH" || method === "PUT") && path.match(/^\/[^/]+$/)) {
|
if (!appointmentId || appointmentId === "consultas") {
|
||||||
const id = path.substring(1);
|
|
||||||
const index = consultas.findIndex((c) => c.id === id);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return {
|
return {
|
||||||
statusCode: 404,
|
statusCode: 400,
|
||||||
headers,
|
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 || "{}");
|
const body = JSON.parse(event.body || "{}");
|
||||||
consultas[index] = {
|
const response = await fetch(
|
||||||
...consultas[index],
|
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
|
||||||
...body,
|
{
|
||||||
id, // Não permitir alterar ID
|
method: "PATCH",
|
||||||
updated_at: new Date().toISOString(),
|
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 {
|
return {
|
||||||
statusCode: 200,
|
statusCode: response.status,
|
||||||
headers,
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(consultas[index]),
|
body: JSON.stringify(data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE - DELETE /consultas/:id
|
if (event.httpMethod === "DELETE") {
|
||||||
if (method === "DELETE" && path.match(/^\/[^/]+$/)) {
|
if (!appointmentId || appointmentId === "consultas") {
|
||||||
const id = path.substring(1);
|
|
||||||
const index = consultas.findIndex((c) => c.id === id);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return {
|
return {
|
||||||
statusCode: 404,
|
statusCode: 400,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ error: "Consulta não encontrada" }),
|
body: JSON.stringify({ error: "ID do agendamento <20> obrigat<61>rio" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const response = await fetch(
|
||||||
consultas.splice(index, 1);
|
`${SUPABASE_URL}/rest/v1/appointments?id=eq.${appointmentId}`,
|
||||||
|
{
|
||||||
return {
|
method: "DELETE",
|
||||||
statusCode: 204,
|
headers: { apikey: SUPABASE_ANON_KEY, Authorization: authHeader },
|
||||||
headers,
|
}
|
||||||
body: "",
|
);
|
||||||
};
|
return { statusCode: response.status, headers, body: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 404,
|
statusCode: 405,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ error: "Rota não encontrada" }),
|
body: JSON.stringify({ error: "Method Not Allowed" }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro na função consultas:", error);
|
console.error("Erro:", error);
|
||||||
return {
|
return {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ error: "Erro interno" }),
|
||||||
error: "Erro interno do servidor",
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 LoginPaciente from "./pages/LoginPaciente";
|
||||||
import LoginSecretaria from "./pages/LoginSecretaria";
|
import LoginSecretaria from "./pages/LoginSecretaria";
|
||||||
import LoginMedico from "./pages/LoginMedico";
|
import LoginMedico from "./pages/LoginMedico";
|
||||||
|
import CadastroMedico from "./pages/CadastroMedico";
|
||||||
import AgendamentoPaciente from "./pages/AgendamentoPaciente";
|
import AgendamentoPaciente from "./pages/AgendamentoPaciente";
|
||||||
import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente";
|
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 PainelMedico from "./pages/PainelMedico";
|
||||||
import PainelSecretaria from "./pages/PainelSecretaria";
|
import PainelSecretaria from "./pages/PainelSecretaria";
|
||||||
import ProntuarioPaciente from "./pages/ProntuarioPaciente";
|
import ProntuarioPaciente from "./pages/ProntuarioPaciente";
|
||||||
import TokenInspector from "./pages/TokenInspector";
|
import TokenInspector from "./pages/TokenInspector";
|
||||||
import AdminDiagnostico from "./pages/AdminDiagnostico";
|
import AdminDiagnostico from "./pages/AdminDiagnostico";
|
||||||
import TesteCadastroSquad18 from "./pages/TesteCadastroSquad18";
|
// import TesteCadastroSquad18 from "./pages/TesteCadastroSquad18"; // Arquivo removido
|
||||||
import PainelAdmin from "./pages/PainelAdmin";
|
import PainelAdmin from "./pages/PainelAdmin";
|
||||||
import CentralAjudaRouter from "./pages/CentralAjudaRouter";
|
import CentralAjudaRouter from "./pages/CentralAjudaRouter";
|
||||||
|
import PerfilMedico from "./pages/PerfilMedico";
|
||||||
|
import PerfilPaciente from "./pages/PerfilPaciente";
|
||||||
|
import ClearCache from "./pages/ClearCache";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
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">
|
<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
|
<a
|
||||||
href="#main-content"
|
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
|
Pular para o conteúdo
|
||||||
</a>
|
</a>
|
||||||
@ -40,15 +46,14 @@ function App() {
|
|||||||
<main id="main-content" className="container mx-auto px-4 py-8">
|
<main id="main-content" className="container mx-auto px-4 py-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/clear-cache" element={<ClearCache />} />
|
||||||
<Route path="/paciente" element={<LoginPaciente />} />
|
<Route path="/paciente" element={<LoginPaciente />} />
|
||||||
<Route path="/login-secretaria" element={<LoginSecretaria />} />
|
<Route path="/login-secretaria" element={<LoginSecretaria />} />
|
||||||
<Route path="/login-medico" element={<LoginMedico />} />
|
<Route path="/login-medico" element={<LoginMedico />} />
|
||||||
<Route path="/cadastro-medico" element={<CadastroMedico />} />
|
<Route path="/cadastro/medico" element={<CadastroMedico />} />
|
||||||
<Route path="/cadastro-paciente" element={<CadastroPaciente />} />
|
|
||||||
<Route path="/dev/token" element={<TokenInspector />} />
|
<Route path="/dev/token" element={<TokenInspector />} />
|
||||||
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
|
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
|
||||||
<Route path="/teste-squad18" element={<TesteCadastroSquad18 />} />
|
{/* <Route path="/teste-squad18" element={<TesteCadastroSquad18 />} /> */}
|
||||||
<Route path="/cadastro" element={<CadastroSecretaria />} />
|
|
||||||
<Route path="/ajuda" element={<CentralAjudaRouter />} />
|
<Route path="/ajuda" element={<CentralAjudaRouter />} />
|
||||||
<Route element={<ProtectedRoute roles={["admin", "gestor"]} />}>
|
<Route element={<ProtectedRoute roles={["admin", "gestor"]} />}>
|
||||||
<Route path="/admin" element={<PainelAdmin />} />
|
<Route path="/admin" element={<PainelAdmin />} />
|
||||||
@ -61,6 +66,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/painel-medico" element={<PainelMedico />} />
|
<Route path="/painel-medico" element={<PainelMedico />} />
|
||||||
|
<Route path="/perfil-medico" element={<PerfilMedico />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@ -82,6 +88,7 @@ function App() {
|
|||||||
element={<AcompanhamentoPaciente />}
|
element={<AcompanhamentoPaciente />}
|
||||||
/>
|
/>
|
||||||
<Route path="/agendamento" element={<AgendamentoPaciente />} />
|
<Route path="/agendamento" element={<AgendamentoPaciente />} />
|
||||||
|
<Route path="/perfil-paciente" element={<PerfilPaciente />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -294,6 +294,19 @@ const AccessibilityMenu: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Resetar Configurações
|
Resetar Configurações
|
||||||
</button>
|
</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">
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-2">
|
||||||
Atalho: Alt + A | ESC fecha
|
Atalho: Alt + A | ESC fecha
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
@ -25,10 +24,13 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { availabilityService } from "../services/availabilityService";
|
import {
|
||||||
import { exceptionService } from "../services/exceptionService";
|
availabilityService,
|
||||||
import { consultaService } from "../services/consultaService";
|
exceptionsService,
|
||||||
import { medicoService } from "../services/medicoService";
|
appointmentService,
|
||||||
|
smsService,
|
||||||
|
} from "../services";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
interface Medico {
|
interface Medico {
|
||||||
id: string;
|
id: string;
|
||||||
@ -78,26 +80,23 @@ const dayOfWeekMap: { [key: number]: keyof Availability } = {
|
|||||||
6: "sabado",
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedMedico) {
|
setFilteredMedicos(medicos);
|
||||||
loadDoctorAvailability();
|
}, [medicos]);
|
||||||
loadDoctorExceptions();
|
|
||||||
}
|
|
||||||
}, [selectedMedico, loadDoctorAvailability, loadDoctorExceptions]);
|
|
||||||
const [medicos, setMedicos] = useState<Medico[]>([]);
|
|
||||||
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>([]);
|
|
||||||
const [selectedMedico, setSelectedMedico] = useState<Medico | null>(null);
|
const [selectedMedico, setSelectedMedico] = useState<Medico | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
|
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Calendar and scheduling states
|
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||||
const [availability, setAvailability] = useState<Availability | null>(null);
|
const [availability, setAvailability] = useState<Availability | null>(null);
|
||||||
@ -112,31 +111,10 @@ export default function AgendamentoConsulta() {
|
|||||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||||
const [bookingError, setBookingError] = useState("");
|
const [bookingError, setBookingError] = useState("");
|
||||||
|
|
||||||
// Load doctors on mount
|
// Removido o carregamento interno de médicos, pois agora vem por prop
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMedicos();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter doctors based on search and specialty
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let filtered = medicos;
|
let filtered = medicos;
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
(medico) =>
|
(medico) =>
|
||||||
@ -144,36 +122,36 @@ export default function AgendamentoConsulta() {
|
|||||||
medico.especialidade.toLowerCase().includes(searchTerm.toLowerCase())
|
medico.especialidade.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSpecialty !== "all") {
|
if (selectedSpecialty !== "all") {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
(medico) => medico.especialidade === selectedSpecialty
|
(medico) => medico.especialidade === selectedSpecialty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredMedicos(filtered);
|
setFilteredMedicos(filtered);
|
||||||
}, [searchTerm, selectedSpecialty, medicos]);
|
}, [searchTerm, selectedSpecialty, medicos]);
|
||||||
|
|
||||||
// Get unique specialties
|
|
||||||
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMedico) {
|
||||||
|
loadDoctorAvailability();
|
||||||
|
loadDoctorExceptions();
|
||||||
|
}
|
||||||
// ... outras declarações de hooks ...
|
// eslint-disable-next-line
|
||||||
|
}, [selectedMedico]);
|
||||||
|
|
||||||
|
|
||||||
// ... outras funções e hooks ...
|
|
||||||
|
|
||||||
|
|
||||||
const loadDoctorAvailability = useCallback(async () => {
|
const loadDoctorAvailability = useCallback(async () => {
|
||||||
if (!selectedMedico) return;
|
if (!selectedMedico) return;
|
||||||
try {
|
try {
|
||||||
const response = await availabilityService.getAvailability(selectedMedico.id);
|
const response = await availabilityService.getAvailability(
|
||||||
if (response && response.success && response.data && response.data.length > 0) {
|
selectedMedico.id
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
response &&
|
||||||
|
response.success &&
|
||||||
|
response.data &&
|
||||||
|
response.data.length > 0
|
||||||
|
) {
|
||||||
const avail = response.data[0];
|
const avail = response.data[0];
|
||||||
setAvailability({
|
setAvailability({
|
||||||
domingo: avail.domingo || { ativo: false, horarios: [] },
|
domingo: avail.domingo || { ativo: false, horarios: [] },
|
||||||
@ -187,8 +165,7 @@ export default function AgendamentoConsulta() {
|
|||||||
} else {
|
} else {
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Erro ao carregar disponibilidade:", error);
|
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
}
|
}
|
||||||
}, [selectedMedico]);
|
}, [selectedMedico]);
|
||||||
@ -196,19 +173,19 @@ export default function AgendamentoConsulta() {
|
|||||||
const loadDoctorExceptions = useCallback(async () => {
|
const loadDoctorExceptions = useCallback(async () => {
|
||||||
if (!selectedMedico) return;
|
if (!selectedMedico) return;
|
||||||
try {
|
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) {
|
if (response && response.success && response.data) {
|
||||||
setExceptions(response.data as Exception[]);
|
setExceptions(response.data as Exception[]);
|
||||||
} else {
|
} else {
|
||||||
setExceptions([]);
|
setExceptions([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Erro ao carregar exceções:", error);
|
|
||||||
setExceptions([]);
|
setExceptions([]);
|
||||||
}
|
}
|
||||||
}, [selectedMedico]);
|
}, [selectedMedico]);
|
||||||
|
|
||||||
// Calculate available slots when date is selected
|
|
||||||
const calculateAvailableSlots = useCallback(() => {
|
const calculateAvailableSlots = useCallback(() => {
|
||||||
if (!selectedDate || !availability) return;
|
if (!selectedDate || !availability) return;
|
||||||
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
||||||
@ -236,7 +213,13 @@ export default function AgendamentoConsulta() {
|
|||||||
} else {
|
} else {
|
||||||
setAvailableSlots([]);
|
setAvailableSlots([]);
|
||||||
}
|
}
|
||||||
}, [selectedDate, availability, exceptions, calculateAvailableSlots, selectedMedico]);
|
}, [
|
||||||
|
selectedDate,
|
||||||
|
availability,
|
||||||
|
exceptions,
|
||||||
|
calculateAvailableSlots,
|
||||||
|
selectedMedico,
|
||||||
|
]);
|
||||||
|
|
||||||
const isDateBlocked = (date: Date): boolean => {
|
const isDateBlocked = (date: Date): boolean => {
|
||||||
const dateStr = format(date, "yyyy-MM-dd");
|
const dateStr = format(date, "yyyy-MM-dd");
|
||||||
@ -245,30 +228,20 @@ export default function AgendamentoConsulta() {
|
|||||||
|
|
||||||
const isDateAvailable = (date: Date): boolean => {
|
const isDateAvailable = (date: Date): boolean => {
|
||||||
if (!availability) return false;
|
if (!availability) return false;
|
||||||
|
|
||||||
// Check if in the past
|
|
||||||
if (isBefore(date, startOfDay(new Date()))) return false;
|
if (isBefore(date, startOfDay(new Date()))) return false;
|
||||||
|
|
||||||
// Check if blocked
|
|
||||||
if (isDateBlocked(date)) return false;
|
if (isDateBlocked(date)) return false;
|
||||||
|
|
||||||
// Check if day has available schedule
|
|
||||||
const dayOfWeek = date.getDay();
|
const dayOfWeek = date.getDay();
|
||||||
const dayKey = dayOfWeekMap[dayOfWeek];
|
const dayKey = dayOfWeekMap[dayOfWeek];
|
||||||
const daySchedule = availability[dayKey];
|
const daySchedule = availability[dayKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
|
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calendar generation
|
|
||||||
const generateCalendarDays = () => {
|
const generateCalendarDays = () => {
|
||||||
const start = startOfMonth(currentMonth);
|
const start = startOfMonth(currentMonth);
|
||||||
const end = endOfMonth(currentMonth);
|
const end = endOfMonth(currentMonth);
|
||||||
const days = eachDayOfInterval({ start, end });
|
const days = eachDayOfInterval({ start, end });
|
||||||
|
|
||||||
// Add padding days from previous month
|
|
||||||
const startDay = start.getDay();
|
const startDay = start.getDay();
|
||||||
const prevMonthDays = [];
|
const prevMonthDays = [];
|
||||||
for (let i = startDay - 1; i >= 0; i--) {
|
for (let i = startDay - 1; i >= 0; i--) {
|
||||||
@ -276,18 +249,11 @@ export default function AgendamentoConsulta() {
|
|||||||
day.setDate(day.getDate() - (i + 1));
|
day.setDate(day.getDate() - (i + 1));
|
||||||
prevMonthDays.push(day);
|
prevMonthDays.push(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...prevMonthDays, ...days];
|
return [...prevMonthDays, ...days];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevMonth = () => {
|
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
|
||||||
setCurrentMonth(subMonths(currentMonth, 1));
|
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextMonth = () => {
|
|
||||||
setCurrentMonth(addMonths(currentMonth, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectDoctor = (medico: Medico) => {
|
const handleSelectDoctor = (medico: Medico) => {
|
||||||
setSelectedMedico(medico);
|
setSelectedMedico(medico);
|
||||||
setSelectedDate(undefined);
|
setSelectedDate(undefined);
|
||||||
@ -296,43 +262,43 @@ export default function AgendamentoConsulta() {
|
|||||||
setBookingSuccess(false);
|
setBookingSuccess(false);
|
||||||
setBookingError("");
|
setBookingError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBookAppointment = () => {
|
const handleBookAppointment = () => {
|
||||||
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
||||||
setShowConfirmDialog(true);
|
setShowConfirmDialog(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmAppointment = async () => {
|
const confirmAppointment = async () => {
|
||||||
if (!selectedMedico || !selectedDate || !selectedTime) return;
|
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setBookingError("");
|
setBookingError("");
|
||||||
|
// Cria o agendamento na API real
|
||||||
// Get current user from localStorage
|
const result = await consultasService.criar({
|
||||||
const userStr = localStorage.getItem("user");
|
patient_id: user.id,
|
||||||
if (!userStr) {
|
doctor_id: selectedMedico.id,
|
||||||
setBookingError("Usuário não autenticado");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
// Envia SMS de confirmação (se telefone disponível)
|
||||||
const user = JSON.parse(userStr);
|
if (user.telefone) {
|
||||||
|
await smsService.enviarConfirmacaoConsulta(
|
||||||
// Removido: dataHora não é usada
|
user.telefone,
|
||||||
|
user.nome || "Paciente",
|
||||||
// Book appointment via API
|
selectedMedico.nome,
|
||||||
await consultaService.criarConsulta({
|
format(selectedDate, "dd/MM/yyyy") + " às " + selectedTime
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
setBookingSuccess(true);
|
setBookingSuccess(true);
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
|
|
||||||
// Reset form after 3 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSelectedMedico(null);
|
setSelectedMedico(null);
|
||||||
setSelectedDate(undefined);
|
setSelectedDate(undefined);
|
||||||
@ -340,183 +306,136 @@ export default function AgendamentoConsulta() {
|
|||||||
setMotivo("");
|
setMotivo("");
|
||||||
setBookingSuccess(false);
|
setBookingSuccess(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao agendar consulta:", error);
|
|
||||||
setBookingError(
|
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);
|
setShowConfirmDialog(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDays = generateCalendarDays();
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6">
|
||||||
{/* Success Message */}
|
|
||||||
{bookingSuccess && (
|
{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">
|
<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 dark:text-green-400" />
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900 dark:text-green-100">
|
<p className="font-medium text-green-900">
|
||||||
Consulta agendada com sucesso!
|
Consulta agendada com sucesso!
|
||||||
</p>
|
</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.
|
Você receberá uma confirmação por e-mail em breve.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{bookingError && (
|
{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">
|
<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 dark:text-red-400" />
|
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||||
<p className="text-red-900 dark:text-red-100">{bookingError}</p>
|
<p className="text-red-900">{bookingError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold">Agendar Consulta</h1>
|
||||||
Agendar Consulta
|
<p className="text-muted-foreground">
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Escolha um médico e horário disponível
|
Escolha um médico e horário disponível
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||||
{/* 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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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
|
Buscar por nome ou especialidade
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Cardiologia, Dr. Silva..."
|
placeholder="Ex: Cardiologia, Dr. Silva..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="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>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="font-medium">Especialidade</label>
|
||||||
Especialidade
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedSpecialty}
|
value={selectedSpecialty}
|
||||||
onChange={(e) => setSelectedSpecialty(e.target.value)}
|
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>
|
<option value="all">Todas as especialidades</option>
|
||||||
{specialties.map((specialty) => (
|
{specialties.map((esp) => (
|
||||||
<option key={specialty} value={specialty}>
|
<option key={esp} value={esp}>
|
||||||
{specialty}
|
{esp}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Doctors List */}
|
{filteredMedicos.map((medico) => (
|
||||||
{loading ? (
|
<div
|
||||||
<div className="text-center py-12">
|
key={medico.id}
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
className={`bg-white rounded-xl border p-6 flex gap-4 items-center ${
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
selectedMedico?.id === medico.id ? "border-blue-500" : ""
|
||||||
Carregando médicos...
|
}`}
|
||||||
</p>
|
>
|
||||||
</div>
|
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold">
|
||||||
) : filteredMedicos.length === 0 ? (
|
{medico.nome
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
.split(" ")
|
||||||
<Stethoscope className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
.map((n) => n[0])
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
.join("")}
|
||||||
Nenhum médico encontrado
|
</div>
|
||||||
</p>
|
<div className="flex-1 space-y-2">
|
||||||
</div>
|
<div>
|
||||||
) : (
|
<h3 className="font-semibold">{medico.nome}</h3>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<p className="text-muted-foreground">{medico.especialidade}</p>
|
||||||
{filteredMedicos.map((medico) => (
|
</div>
|
||||||
<div
|
<div className="flex items-center gap-4 text-muted-foreground">
|
||||||
key={medico.id}
|
<span>{medico.crm}</span>
|
||||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-6 transition-all ${
|
{medico.valorConsulta ? (
|
||||||
selectedMedico?.id === medico.id ? "ring-2 ring-blue-500" : ""
|
<span>R$ {medico.valorConsulta.toFixed(2)}</span>
|
||||||
}`}
|
) : null}
|
||||||
>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<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">
|
<span className="text-foreground">{medico.email || "-"}</span>
|
||||||
{medico.nome
|
<div className="flex gap-2">
|
||||||
.split(" ")
|
<button
|
||||||
.map((n) => n[0])
|
className="px-3 py-1 rounded-lg border text-sm hover:bg-blue-50"
|
||||||
.join("")
|
onClick={() => handleSelectDoctor(medico)}
|
||||||
.substring(0, 2)}
|
>
|
||||||
</div>
|
{selectedMedico?.id === medico.id
|
||||||
<div className="flex-1 space-y-2">
|
? "Selecionado"
|
||||||
<div>
|
: "Selecionar"}
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
</button>
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Appointment Details */}
|
|
||||||
{selectedMedico && (
|
{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>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold">Detalhes do Agendamento</h2>
|
||||||
Detalhes do Agendamento
|
<p className="text-gray-600">
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Consulta com {selectedMedico.nome} -{" "}
|
Consulta com {selectedMedico.nome} -{" "}
|
||||||
{selectedMedico.especialidade}
|
{selectedMedico.especialidade}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appointment Type */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAppointmentType("presencial")}
|
onClick={() => setAppointmentType("presencial")}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||||
appointmentType === "presencial"
|
appointmentType === "presencial"
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
|
: "border-gray-300 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MapPin className="h-5 w-5" />
|
<MapPin className="h-5 w-5" />
|
||||||
@ -526,59 +445,49 @@ export default function AgendamentoConsulta() {
|
|||||||
onClick={() => setAppointmentType("online")}
|
onClick={() => setAppointmentType("online")}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||||
appointmentType === "online"
|
appointmentType === "online"
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
|
: "border-gray-300 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Video className="h-5 w-5" />
|
<Video className="h-5 w-5" />
|
||||||
<span className="font-medium">Online</span>
|
<span className="font-medium">Online</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Calendar */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="text-sm font-medium">Selecione a Data</label>
|
||||||
Selecione a Data
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{/* Month/Year Navigation */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={handlePrevMonth}
|
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" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">
|
<span className="font-semibold">
|
||||||
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleNextMonth}
|
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" />
|
<ChevronRight className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* Calendar Grid */}
|
<div className="grid grid-cols-7 bg-gray-50">
|
||||||
<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">
|
|
||||||
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
|
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
|
||||||
(day) => (
|
(day) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
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}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar days */}
|
|
||||||
<div className="grid grid-cols-7">
|
<div className="grid grid-cols-7">
|
||||||
{calendarDays.map((day, index) => {
|
{calendarDays.map((day, index) => {
|
||||||
const isCurrentMonth = isSameMonth(day, currentMonth);
|
const isCurrentMonth = isSameMonth(day, currentMonth);
|
||||||
@ -589,53 +498,37 @@ export default function AgendamentoConsulta() {
|
|||||||
isCurrentMonth && isDateAvailable(day);
|
isCurrentMonth && isDateAvailable(day);
|
||||||
const isBlocked = isCurrentMonth && isDateBlocked(day);
|
const isBlocked = isCurrentMonth && isDateBlocked(day);
|
||||||
const isPast = isBefore(day, startOfDay(new Date()));
|
const isPast = isBefore(day, startOfDay(new Date()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => isAvailable && setSelectedDate(day)}
|
onClick={() => isAvailable && setSelectedDate(day)}
|
||||||
disabled={!isAvailable}
|
disabled={!isAvailable}
|
||||||
className={`
|
className={`aspect-square p-2 text-sm border-r border-b border-gray-200 ${
|
||||||
aspect-square p-2 text-sm border-r border-b border-gray-200 dark:border-gray-700
|
!isCurrentMonth ? "text-gray-300 bg-gray-50" : ""
|
||||||
${
|
} ${
|
||||||
!isCurrentMonth
|
isSelected
|
||||||
? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-800"
|
? "bg-blue-600 text-white font-bold"
|
||||||
: ""
|
: ""
|
||||||
}
|
} ${
|
||||||
${
|
isTodayDate && !isSelected
|
||||||
isSelected
|
? "font-bold text-blue-600"
|
||||||
? "bg-blue-600 text-white font-bold"
|
: ""
|
||||||
: ""
|
} ${
|
||||||
}
|
isAvailable && !isSelected
|
||||||
${
|
? "hover:bg-blue-50 cursor-pointer"
|
||||||
isTodayDate && !isSelected
|
: ""
|
||||||
? "font-bold text-blue-600 dark:text-blue-400"
|
} ${
|
||||||
: ""
|
isBlocked
|
||||||
}
|
? "bg-red-50 text-red-400 line-through"
|
||||||
${
|
: ""
|
||||||
isAvailable && !isSelected
|
} ${isPast && !isBlocked ? "text-gray-400" : ""} ${
|
||||||
? "hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer"
|
!isAvailable &&
|
||||||
: ""
|
!isBlocked &&
|
||||||
}
|
isCurrentMonth &&
|
||||||
${
|
!isPast
|
||||||
isBlocked
|
? "text-gray-300"
|
||||||
? "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"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{format(day, "d")}
|
||||||
</button>
|
</button>
|
||||||
@ -643,35 +536,30 @@ export default function AgendamentoConsulta() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 space-y-1 text-xs text-gray-600">
|
||||||
{/* Legend */}
|
|
||||||
<div className="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<p>🟢 Datas disponíveis</p>
|
<p>🟢 Datas disponíveis</p>
|
||||||
<p>🔴 Datas bloqueadas</p>
|
<p>🔴 Datas bloqueadas</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Slots and Details */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="text-sm font-medium">
|
||||||
Horários Disponíveis
|
Horários Disponíveis
|
||||||
</label>
|
</label>
|
||||||
{selectedDate ? (
|
{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", {
|
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
})}
|
})}
|
||||||
</p>
|
</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
|
Selecione uma data
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedDate && availableSlots.length > 0 ? (
|
{selectedDate && availableSlots.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{availableSlots.map((slot) => (
|
{availableSlots.map((slot) => (
|
||||||
@ -680,8 +568,8 @@ export default function AgendamentoConsulta() {
|
|||||||
onClick={() => setSelectedTime(slot)}
|
onClick={() => setSelectedTime(slot)}
|
||||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
|
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
|
||||||
selectedTime === slot
|
selectedTime === slot
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
|
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
|
||||||
: "border-gray-300 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-700"
|
: "border-gray-300 hover:border-blue-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
@ -690,22 +578,20 @@ export default function AgendamentoConsulta() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : selectedDate ? (
|
) : 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">
|
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600">
|
||||||
Nenhum horário disponível para esta data
|
Nenhum horário disponível para esta data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-center">
|
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600">
|
||||||
Selecione uma data para ver os horários
|
Selecione uma data para ver os horários
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reason */}
|
|
||||||
<div className="space-y-2">
|
<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 *
|
Motivo da Consulta *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -713,17 +599,13 @@ export default function AgendamentoConsulta() {
|
|||||||
value={motivo}
|
value={motivo}
|
||||||
onChange={(e) => setMotivo(e.target.value)}
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full px-4 py-2 border border-gray-300 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>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
{selectedDate && selectedTime && (
|
{selectedDate && selectedTime && (
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg space-y-2">
|
<div className="p-4 bg-blue-50 rounded-lg space-y-2">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
<h4 className="font-semibold">Resumo</h4>
|
||||||
Resumo
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
</h4>
|
|
||||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
||||||
<p>⏰ Horário: {selectedTime}</p>
|
<p>⏰ Horário: {selectedTime}</p>
|
||||||
<p>
|
<p>
|
||||||
@ -738,12 +620,10 @@ export default function AgendamentoConsulta() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleBookAppointment}
|
onClick={handleBookAppointment}
|
||||||
disabled={!selectedTime || !motivo.trim()}
|
disabled={!selectedTime || !motivo.trim()}
|
||||||
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 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
|
Confirmar Agendamento
|
||||||
</button>
|
</button>
|
||||||
@ -751,21 +631,16 @@ export default function AgendamentoConsulta() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirmDialog && (
|
{showConfirmDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-xl font-semibold">Confirmar Agendamento</h3>
|
||||||
Confirmar Agendamento
|
<p className="text-gray-600">
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Revise os detalhes da sua consulta antes de confirmar
|
Revise os detalhes da sua consulta antes de confirmar
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-12 w-12 rounded-full bg-blue-100 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
|
{selectedMedico?.nome
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
@ -773,16 +648,15 @@ export default function AgendamentoConsulta() {
|
|||||||
.substring(0, 2)}
|
.substring(0, 2)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
<p className="font-medium text-gray-900">
|
||||||
{selectedMedico?.nome}
|
{selectedMedico?.nome}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600">
|
||||||
{selectedMedico?.especialidade}
|
{selectedMedico?.especialidade}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p>
|
<p>
|
||||||
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
||||||
</p>
|
</p>
|
||||||
@ -796,19 +670,16 @@ export default function AgendamentoConsulta() {
|
|||||||
{selectedMedico?.valorConsulta && (
|
{selectedMedico?.valorConsulta && (
|
||||||
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
|
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||||
<p className="font-medium text-gray-900 dark:text-white mb-1">
|
<p className="font-medium text-gray-900 mb-1">Motivo:</p>
|
||||||
Motivo:
|
<p className="text-gray-600">{motivo}</p>
|
||||||
</p>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">{motivo}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfirmDialog(false)}
|
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
|
Cancelar
|
||||||
</button>
|
</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 React, { useState, useEffect } from "react";
|
||||||
import {
|
import { Clock, Plus, Trash2, Save, Copy } from "lucide-react";
|
||||||
Clock,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Save,
|
|
||||||
Copy,
|
|
||||||
} from "lucide-react";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import availabilityService from "../services/availabilityService";
|
import { availabilityService, exceptionsService } from "../services/index";
|
||||||
import exceptionService, { DoctorException } from "../services/exceptionService";
|
import type { DoctorException } from "../services/exceptions/types";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
@ -68,10 +62,11 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
const loadAvailability = React.useCallback(async () => {
|
const loadAvailability = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Usar listAvailability ao invés de getAvailability para ter os IDs individuais
|
const availabilities = await availabilityService.list({
|
||||||
const response = await availabilityService.listAvailability({ doctor_id: medicoId });
|
doctor_id: medicoId,
|
||||||
|
});
|
||||||
|
|
||||||
if (response && response.success && response.data && response.data.length > 0) {
|
if (availabilities && availabilities.length > 0) {
|
||||||
const newSchedule: Record<number, DaySchedule> = {};
|
const newSchedule: Record<number, DaySchedule> = {};
|
||||||
|
|
||||||
// Inicializar todos os dias
|
// Inicializar todos os dias
|
||||||
@ -85,8 +80,8 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Agrupar disponibilidades por dia da semana
|
// Agrupar disponibilidades por dia da semana
|
||||||
response.data.forEach((avail) => {
|
availabilities.forEach((avail: any) => {
|
||||||
const weekdayKey = daysOfWeek.find(d => d.dbKey === avail.weekday);
|
const weekdayKey = daysOfWeek.find((d) => d.dbKey === avail.weekday);
|
||||||
if (!weekdayKey) return;
|
if (!weekdayKey) return;
|
||||||
|
|
||||||
const dayKey = weekdayKey.key;
|
const dayKey = weekdayKey.key;
|
||||||
@ -127,14 +122,14 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
|
|
||||||
const loadExceptions = React.useCallback(async () => {
|
const loadExceptions = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await exceptionService.listExceptions({ doctor_id: medicoId });
|
const exceptions = await exceptionsService.list({
|
||||||
if (response.success && response.data) {
|
doctor_id: medicoId,
|
||||||
setExceptions(response.data);
|
});
|
||||||
const blocked = response.data
|
setExceptions(exceptions);
|
||||||
.filter((exc) => exc.kind === "bloqueio" && exc.date)
|
const blocked = exceptions
|
||||||
.map((exc) => new Date(exc.date!));
|
.filter((exc: any) => exc.kind === "bloqueio" && exc.date)
|
||||||
setBlockedDates(blocked);
|
.map((exc: any) => new Date(exc.date!));
|
||||||
}
|
setBlockedDates(blocked);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar exceções:", error);
|
console.error("Erro ao carregar exceções:", error);
|
||||||
}
|
}
|
||||||
@ -182,18 +177,13 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeTimeSlot = async (dayKey: number, slotId: string) => {
|
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
|
// Se o slot tem um ID do banco, deletar imediatamente
|
||||||
if (slot?.dbId) {
|
if (slot?.dbId) {
|
||||||
try {
|
try {
|
||||||
const response = await availabilityService.deleteAvailability(slot.dbId);
|
await availabilityService.delete(slot.dbId);
|
||||||
if (response.success) {
|
toast.success("Horário removido com sucesso");
|
||||||
toast.success("Horário removido com sucesso");
|
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao remover horário");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao remover horário:", error);
|
console.error("Erro ao remover horário:", error);
|
||||||
toast.error("Erro ao remover horário");
|
toast.error("Erro ao remover horário");
|
||||||
@ -270,7 +260,7 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
// Se o dia foi desabilitado, deletar todos os slots existentes
|
// Se o dia foi desabilitado, deletar todos os slots existentes
|
||||||
daySchedule?.slots.forEach((slot) => {
|
daySchedule?.slots.forEach((slot) => {
|
||||||
if (slot.dbId) {
|
if (slot.dbId) {
|
||||||
requests.push(availabilityService.deleteAvailability(slot.dbId));
|
requests.push(availabilityService.delete(slot.dbId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -278,12 +268,30 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
|
|
||||||
// Processar cada slot do dia
|
// Processar cada slot do dia
|
||||||
daySchedule.slots.forEach((slot) => {
|
daySchedule.slots.forEach((slot) => {
|
||||||
const inicio = slot.inicio ? (slot.inicio.length === 5 ? `${slot.inicio}:00` : slot.inicio) : "00:00:00";
|
const inicio = slot.inicio
|
||||||
const fim = slot.fim ? (slot.fim.length === 5 ? `${slot.fim}:00` : slot.fim) : "00:00:00";
|
? slot.inicio.length === 5
|
||||||
const minutes = Math.max(1, timeToMinutes(fim.slice(0,5)) - timeToMinutes(inicio.slice(0,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 = {
|
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,
|
start_time: inicio,
|
||||||
end_time: fim,
|
end_time: fim,
|
||||||
slot_minutes: minutes,
|
slot_minutes: minutes,
|
||||||
@ -293,13 +301,15 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
|
|
||||||
if (slot.dbId) {
|
if (slot.dbId) {
|
||||||
// Atualizar slot existente
|
// Atualizar slot existente
|
||||||
requests.push(availabilityService.updateAvailability(slot.dbId, payload));
|
requests.push(availabilityService.update(slot.dbId, payload));
|
||||||
} else {
|
} else {
|
||||||
// Criar novo slot
|
// Criar novo slot
|
||||||
requests.push(availabilityService.createAvailability({
|
requests.push(
|
||||||
doctor_id: medicoId,
|
availabilityService.create({
|
||||||
...payload,
|
doctor_id: medicoId,
|
||||||
}));
|
...payload,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -314,9 +324,14 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
results.forEach((r, idx) => {
|
results.forEach((r, idx) => {
|
||||||
if (r.status === "fulfilled") {
|
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++;
|
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 {
|
} else {
|
||||||
errors.push(`Item ${idx}: ${r.reason?.message || String(r.reason)}`);
|
errors.push(`Item ${idx}: ${r.reason?.message || String(r.reason)}`);
|
||||||
}
|
}
|
||||||
@ -324,7 +339,9 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.error("Erros ao salvar disponibilidades:", errors);
|
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) {
|
if (successCount > 0) {
|
||||||
toast.success(`${successCount} alteração(ões) salvas com sucesso!`);
|
toast.success(`${successCount} alteração(ões) salvas com sucesso!`);
|
||||||
@ -332,7 +349,10 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao salvar disponibilidade:", 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);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@ -351,10 +371,11 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
if (dateExists) {
|
if (dateExists) {
|
||||||
// Remove block
|
// Remove block
|
||||||
const exception = exceptions.find(
|
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) {
|
if (exception && exception.id) {
|
||||||
await exceptionService.deleteException(exception.id);
|
await exceptionsService.delete(exception.id);
|
||||||
setBlockedDates(
|
setBlockedDates(
|
||||||
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
||||||
);
|
);
|
||||||
@ -362,16 +383,14 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add block
|
// Add block
|
||||||
const response = await exceptionService.createException({
|
await exceptionsService.create({
|
||||||
doctor_id: medicoId,
|
doctor_id: medicoId,
|
||||||
date: dateString,
|
date: dateString,
|
||||||
kind: "bloqueio",
|
kind: "bloqueio",
|
||||||
reason: "Data bloqueada pelo médico",
|
reason: "Data bloqueada pelo médico",
|
||||||
});
|
});
|
||||||
if (response.success) {
|
setBlockedDates([...blockedDates, selectedDate]);
|
||||||
setBlockedDates([...blockedDates, selectedDate]);
|
toast.success("Data bloqueada");
|
||||||
toast.success("Data bloqueada");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
loadExceptions();
|
loadExceptions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
|
|||||||
import { User, Stethoscope, Clipboard, ChevronDown } from "lucide-react";
|
import { User, Stethoscope, Clipboard, ChevronDown } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { i18n } from "../i18n";
|
import { i18n } from "../i18n";
|
||||||
import { telemetry } from "../services/telemetry";
|
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
export type ProfileType = "patient" | "doctor" | "secretary" | null;
|
export type ProfileType = "patient" | "doctor" | "secretary" | null;
|
||||||
@ -95,8 +94,10 @@ export const ProfileSelector: React.FC = () => {
|
|||||||
localStorage.setItem("mediconnect_selected_profile", profile.type);
|
localStorage.setItem("mediconnect_selected_profile", profile.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telemetria
|
// Telemetria (optional - could be implemented later)
|
||||||
telemetry.trackProfileChange(previousProfile, profile.type || "none");
|
console.log(
|
||||||
|
`Profile changed: ${previousProfile} -> ${profile.type || "none"}`
|
||||||
|
);
|
||||||
|
|
||||||
// Navegar - condicional baseado em autenticação e role
|
// Navegar - condicional baseado em autenticação e role
|
||||||
let targetPath = profile.path; // default: caminho do perfil (login)
|
let targetPath = profile.path; // default: caminho do perfil (login)
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import { availabilityService } from "../../services";
|
|||||||
import type {
|
import type {
|
||||||
DoctorAvailability,
|
DoctorAvailability,
|
||||||
Weekday,
|
Weekday,
|
||||||
AppointmentType,
|
} from "../../services/availability/types";
|
||||||
} from "../../services/availabilityService";
|
|
||||||
|
type AppointmentType = "presencial" | "telemedicina";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doctorId: string;
|
doctorId: string;
|
||||||
@ -47,11 +48,14 @@ const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!doctorId) return;
|
if (!doctorId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await availabilityService.listDoctorActiveAvailability(
|
try {
|
||||||
doctorId
|
const data = await availabilityService.list({ doctor_id: doctorId });
|
||||||
);
|
setList(Array.isArray(data) ? data : []);
|
||||||
if (res.success && res.data) setList(res.data);
|
} catch (error) {
|
||||||
else toast.error(res.error || "Erro ao carregar disponibilidades");
|
console.error("[AvailabilityManager] Erro ao carregar:", error);
|
||||||
|
toast.error("Erro ao carregar disponibilidades");
|
||||||
|
setList([]);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,39 +97,44 @@ const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
console.log("[AvailabilityManager] Enviando payload:", payload);
|
console.log("[AvailabilityManager] Enviando payload:", payload);
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const res = await availabilityService.createAvailability(payload);
|
try {
|
||||||
setSaving(false);
|
await availabilityService.create(payload);
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
toast.success("Disponibilidade criada com sucesso!");
|
toast.success("Disponibilidade criada com sucesso!");
|
||||||
setForm((f) => ({ ...f, start_time: "09:00:00", end_time: "17:00:00" }));
|
setForm((f) => ({ ...f, start_time: "09:00:00", end_time: "17:00:00" }));
|
||||||
void load();
|
void load();
|
||||||
} else {
|
} catch (error) {
|
||||||
console.error("[AvailabilityManager] Erro ao criar:", res.error);
|
console.error("[AvailabilityManager] Erro ao criar:", error);
|
||||||
toast.error(res.error || "Falha ao criar disponibilidade");
|
toast.error("Falha ao criar disponibilidade");
|
||||||
}
|
}
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(item: DoctorAvailability) {
|
async function toggleActive(item: DoctorAvailability) {
|
||||||
if (!item.id) return;
|
if (!item.id) return;
|
||||||
const res = await availabilityService.updateAvailability(item.id, {
|
try {
|
||||||
active: !item.active,
|
await availabilityService.update(item.id, {
|
||||||
});
|
active: !item.active,
|
||||||
if (res.success) {
|
});
|
||||||
toast.success("Atualizado");
|
toast.success("Atualizado");
|
||||||
void load();
|
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) {
|
async function remove(item: DoctorAvailability) {
|
||||||
if (!item.id) return;
|
if (!item.id) return;
|
||||||
const ok = confirm("Remover disponibilidade?");
|
const ok = confirm("Remover disponibilidade?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
const res = await availabilityService.deleteAvailability(item.id);
|
try {
|
||||||
if (res.success) {
|
await availabilityService.delete(item.id);
|
||||||
toast.success("Removido");
|
toast.success("Removido");
|
||||||
void load();
|
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 (
|
return (
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
|
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { appointmentService } from "../../services";
|
import { appointmentService, patientService } from "../../services/index";
|
||||||
import pacienteService from "../../services/pacienteService";
|
import type { Appointment } from "../../services/appointments/types";
|
||||||
import type { Appointment } from "../../services/appointmentService";
|
|
||||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -52,16 +51,12 @@ const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
|
|||||||
async function loadAppointments() {
|
async function loadAppointments() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await appointmentService.listAppointments();
|
const appointments = await appointmentService.list();
|
||||||
if (response.success && response.data) {
|
// Filtrar apenas do médico selecionado
|
||||||
// Filtrar apenas do médico selecionado
|
const filtered = appointments.filter(
|
||||||
const filtered = response.data.filter(
|
(apt: Appointment) => apt.doctor_id === doctorId
|
||||||
(apt) => apt.doctor_id === doctorId
|
);
|
||||||
);
|
setAppointments(filtered);
|
||||||
setAppointments(filtered);
|
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao carregar agendamentos");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar agendamentos:", error);
|
console.error("Erro ao carregar agendamentos:", error);
|
||||||
toast.error("Erro ao carregar agendamentos");
|
toast.error("Erro ao carregar agendamentos");
|
||||||
@ -73,52 +68,16 @@ const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
|
|||||||
async function loadPatients() {
|
async function loadPatients() {
|
||||||
// Carrega pacientes para mapear nome pelo id (render amigável)
|
// Carrega pacientes para mapear nome pelo id (render amigável)
|
||||||
try {
|
try {
|
||||||
const res = await pacienteService.listPatients();
|
const patients = await patientService.list();
|
||||||
if (res && Array.isArray(res.data)) {
|
const map: Record<string, string> = {};
|
||||||
const map: Record<string, string> = {};
|
for (const p of patients) {
|
||||||
for (const p of res.data) {
|
if (p?.id) {
|
||||||
if (p?.id) map[p.id] = p.nome || p.email || p.cpf || 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 {
|
} catch {
|
||||||
// silencioso; não bloqueia calendário
|
// silencioso; não bloqueia calendário
|
||||||
} finally {
|
|
||||||
/* no-op */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { exceptionService } from "../../services";
|
import { exceptionsService } from "../../services/index";
|
||||||
import type {
|
import type {
|
||||||
DoctorException,
|
DoctorException,
|
||||||
ExceptionKind,
|
ExceptionKind,
|
||||||
} from "../../services/exceptionService";
|
} from "../../services/exceptions/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doctorId: string;
|
doctorId: string;
|
||||||
@ -25,10 +25,15 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!doctorId) return;
|
if (!doctorId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await exceptionService.listExceptions({ doctor_id: doctorId });
|
try {
|
||||||
if (res.success && res.data) setList(res.data);
|
const exceptions = await exceptionsService.list({ doctor_id: doctorId });
|
||||||
else toast.error(res.error || "Erro ao carregar exceções");
|
setList(exceptions);
|
||||||
setLoading(false);
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar exceções:", error);
|
||||||
|
toast.error("Erro ao carregar exceções");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,16 +48,15 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const res = await exceptionService.createException({
|
try {
|
||||||
doctor_id: doctorId,
|
await exceptionsService.create({
|
||||||
date: form.date,
|
doctor_id: doctorId,
|
||||||
start_time: form.start_time || undefined,
|
date: form.date,
|
||||||
end_time: form.end_time || undefined,
|
start_time: form.start_time || undefined,
|
||||||
kind: form.kind,
|
end_time: form.end_time || undefined,
|
||||||
reason: form.reason || undefined,
|
kind: form.kind,
|
||||||
});
|
reason: form.reason || undefined,
|
||||||
setSaving(false);
|
});
|
||||||
if (res.success) {
|
|
||||||
toast.success("Exceção criada");
|
toast.success("Exceção criada");
|
||||||
setForm({
|
setForm({
|
||||||
date: "",
|
date: "",
|
||||||
@ -62,18 +66,26 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
reason: "",
|
reason: "",
|
||||||
});
|
});
|
||||||
void load();
|
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) {
|
async function remove(item: DoctorException) {
|
||||||
if (!item.id) return;
|
if (!item.id) return;
|
||||||
const ok = confirm("Remover exceção?");
|
const ok = confirm("Remover exceção?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
const res = await exceptionService.deleteException(item.id);
|
try {
|
||||||
if (res.success) {
|
await exceptionsService.delete(item.id);
|
||||||
toast.success("Removida");
|
toast.success("Removida");
|
||||||
void load();
|
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 (
|
return (
|
||||||
|
|||||||
@ -8,10 +8,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { appointmentService } from "../../services";
|
import {
|
||||||
import medicoService, { type Medico } from "../../services/medicoService";
|
appointmentService,
|
||||||
import pacienteService from "../../services/pacienteService";
|
doctorService,
|
||||||
import type { Paciente as PacienteModel } from "../../services/pacienteService";
|
patientService,
|
||||||
|
} from "../../services/index";
|
||||||
|
import type { Patient } from "../../services/patients/types";
|
||||||
|
import type { Doctor } from "../../services/doctors/types";
|
||||||
import AvailableSlotsPicker from "./AvailableSlotsPicker";
|
import AvailableSlotsPicker from "./AvailableSlotsPicker";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -29,9 +32,9 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
patientName,
|
patientName,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
const [doctors, setDoctors] = useState<Medico[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [loadingDoctors, setLoadingDoctors] = useState(false);
|
const [loadingDoctors, setLoadingDoctors] = useState(false);
|
||||||
const [patients, setPatients] = useState<PacienteModel[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
const [loadingPatients, setLoadingPatients] = useState(false);
|
||||||
|
|
||||||
const [selectedDoctorId, setSelectedDoctorId] = useState("");
|
const [selectedDoctorId, setSelectedDoctorId] = useState("");
|
||||||
@ -79,37 +82,27 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
async function loadDoctors() {
|
async function loadDoctors() {
|
||||||
setLoadingDoctors(true);
|
setLoadingDoctors(true);
|
||||||
const res = await medicoService.listarMedicos();
|
try {
|
||||||
setLoadingDoctors(false);
|
const doctors = await doctorService.list();
|
||||||
if (res.success && res.data) {
|
setDoctors(doctors);
|
||||||
setDoctors(res.data.data); // res.data é MedicoListResponse, res.data.data é Medico[]
|
} catch (error) {
|
||||||
} else {
|
console.error("Erro ao carregar médicos:", error);
|
||||||
toast.error("Erro ao carregar médicos");
|
toast.error("Erro ao carregar médicos");
|
||||||
|
} finally {
|
||||||
|
setLoadingDoctors(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPatients() {
|
async function loadPatients() {
|
||||||
setLoadingPatients(true);
|
setLoadingPatients(true);
|
||||||
try {
|
try {
|
||||||
const res = await pacienteService.listPatients();
|
const patients = await patientService.list();
|
||||||
setLoadingPatients(false);
|
setPatients(patients);
|
||||||
if (res && Array.isArray(res.data)) {
|
} catch (error) {
|
||||||
setPatients(res.data);
|
console.error("Erro ao carregar pacientes:", error);
|
||||||
} 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);
|
|
||||||
toast.error("Erro ao carregar pacientes");
|
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 datetime = `${selectedDate}T${selectedTime}:00`;
|
||||||
|
|
||||||
const res = await appointmentService.createAppointment({
|
try {
|
||||||
patient_id: finalPatientId,
|
await appointmentService.create({
|
||||||
doctor_id: selectedDoctorId,
|
patient_id: finalPatientId,
|
||||||
scheduled_at: datetime,
|
doctor_id: selectedDoctorId,
|
||||||
appointment_type: appointmentType,
|
scheduled_at: datetime,
|
||||||
chief_complaint: reason || undefined,
|
appointment_type: appointmentType,
|
||||||
});
|
chief_complaint: reason || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
toast.success("Agendamento criado com sucesso!");
|
toast.success("Agendamento criado com sucesso!");
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} catch (error) {
|
||||||
toast.error(res.error || "Erro ao criar agendamento");
|
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
|
const effectivePatientName = patientPreselected
|
||||||
? patientName
|
? patientName
|
||||||
: selectedPatientName ||
|
: selectedPatientName ||
|
||||||
(patients.find((p) => p.id === selectedPatientId)?.nome ?? "");
|
(patients.find((p) => p.id === selectedPatientId)?.full_name ?? "");
|
||||||
|
|
||||||
// UX: handlers para ESC e clique fora
|
// UX: handlers para ESC e clique fora
|
||||||
function onKeyDown(e: React.KeyboardEvent) {
|
function onKeyDown(e: React.KeyboardEvent) {
|
||||||
@ -249,7 +243,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedPatientId(e.target.value);
|
setSelectedPatientId(e.target.value);
|
||||||
const p = patients.find((px) => px.id === 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"
|
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
|
required
|
||||||
@ -257,7 +251,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
<option value="">-- Selecione um paciente --</option>
|
<option value="">-- Selecione um paciente --</option>
|
||||||
{patients.map((p) => (
|
{patients.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.nome} {p.cpf ? `- ${p.cpf}` : ""}
|
{p.full_name} {p.cpf ? `- ${p.cpf}` : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -289,7 +283,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
<option value="">-- Selecione um médico --</option>
|
<option value="">-- Selecione um médico --</option>
|
||||||
{doctors.map((doc) => (
|
{doctors.map((doc) => (
|
||||||
<option key={doc.id} value={doc.id}>
|
<option key={doc.id} value={doc.id}>
|
||||||
{doc.nome} - {doc.especialidade}
|
{doc.full_name} - {doc.specialty}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -1,14 +1,25 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X, Loader2 } from "lucide-react";
|
||||||
import consultasService, {
|
import {
|
||||||
Consulta,
|
appointmentService,
|
||||||
ConsultaCreate,
|
patientService,
|
||||||
ConsultaUpdate,
|
doctorService,
|
||||||
} from "../../services/consultasService";
|
type Appointment,
|
||||||
import { listPatients, Paciente } from "../../services/pacienteService";
|
type Patient,
|
||||||
import { medicoService, Medico } from "../../services/medicoService";
|
type Doctor,
|
||||||
|
} from "../../services";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
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 {
|
interface ConsultaModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -62,22 +73,13 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingLists(true);
|
setLoadingLists(true);
|
||||||
const [pacsResp, medsResp] = await Promise.all([
|
const [patients, doctors] = await Promise.all([
|
||||||
listPatients().catch(() => ({
|
patientService.list().catch(() => []),
|
||||||
data: [],
|
doctorService.list().catch(() => []),
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
per_page: 0,
|
|
||||||
})),
|
|
||||||
medicoService
|
|
||||||
.listarMedicos()
|
|
||||||
.catch(() => ({ success: false, data: undefined })),
|
|
||||||
]);
|
]);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setPacientes(pacsResp.data);
|
setPacientes(patients);
|
||||||
if (medsResp && medsResp.success && medsResp.data) {
|
setMedicos(doctors);
|
||||||
setMedicos(medsResp.data.data);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoadingLists(false);
|
if (active) setLoadingLists(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,18 @@
|
|||||||
import { useState, useContext } from "react";
|
import { useContext } from "react";
|
||||||
import AuthContext from "../../context/AuthContext";
|
import AuthContext from "../../context/AuthContext";
|
||||||
import React from "react";
|
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 {
|
export interface PacienteFormData {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -66,56 +77,11 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
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
|
// Obtem role do usuário autenticado
|
||||||
const auth = useContext(AuthContext);
|
const auth = useContext(AuthContext);
|
||||||
const canEditAvatar = ["secretaria", "admin", "gestor"].includes(auth?.user?.role || "");
|
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@ -124,57 +90,30 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
noValidate
|
noValidate
|
||||||
aria-describedby={cpfError ? "cpf-error" : undefined}
|
aria-describedby={cpfError ? "cpf-error" : undefined}
|
||||||
>
|
>
|
||||||
{/* Bloco do avatar antes do título dos dados pessoais */}
|
{/* Avatar com upload */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6 pb-6 border-b border-gray-200">
|
||||||
<div className="relative group">
|
<AvatarUpload
|
||||||
{data.avatar_url ? (
|
userId={data.id}
|
||||||
<img
|
currentAvatarUrl={data.avatar_url}
|
||||||
src={data.avatar_url}
|
name={data.nome || "Paciente"}
|
||||||
alt={data.nome}
|
color="blue"
|
||||||
className="h-16 w-16 rounded-full object-cover border shadow"
|
size="xl"
|
||||||
/>
|
editable={canEditAvatar && !!data.id}
|
||||||
) : (
|
onAvatarUpdate={(avatarUrl) => {
|
||||||
<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">
|
onChange({ avatar_url: avatarUrl || undefined });
|
||||||
{data.nome
|
}}
|
||||||
.split(" ")
|
/>
|
||||||
.map((n) => n[0])
|
<div className="flex-1">
|
||||||
.join("")
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
.toUpperCase()
|
{data.nome || "Novo Paciente"}
|
||||||
.slice(0, 2)}
|
</h3>
|
||||||
</div>
|
{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>}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Todos os campos do formulário já estão dentro do <form> abaixo do avatar */}
|
{/* 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">
|
<div className="md:col-span-2">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-green-600">
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-green-600">
|
||||||
Dados pessoais
|
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,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import medicoService, { type Medico } from "../services/medicoService";
|
import { authService, userService } from "../services";
|
||||||
import authService, {
|
|
||||||
type UserInfoFullResponse,
|
// Tipos auxiliares
|
||||||
} from "../services/authService"; // tokens + user-info
|
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)
|
// tokenManager removido no modelo somente Supabase (sem usuário técnico)
|
||||||
|
|
||||||
// Tipos de roles suportados
|
// Tipos de roles suportados
|
||||||
@ -115,13 +139,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
parsed.user.nome
|
parsed.user.nome
|
||||||
);
|
);
|
||||||
setUser(parsed.user);
|
setUser(parsed.user);
|
||||||
|
// Token restoration is handled automatically by authService
|
||||||
// Restaurar tokens também
|
|
||||||
if (parsed.token) {
|
|
||||||
import("../services/tokenStore").then((module) => {
|
|
||||||
module.default.setTokens(parsed.token!, parsed.refreshToken);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AuthContext] Erro ao recuperar sessão:", 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,
|
hasToken: !!parsed.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verificar se há tokens válidos salvos
|
// Token management is handled automatically by authService
|
||||||
if (parsed.token) {
|
if (parsed.token) {
|
||||||
console.log("[AuthContext] Restaurando tokens no tokenStore");
|
console.log("[AuthContext] Sessão com token encontrada");
|
||||||
const tokenStore = (await import("../services/tokenStore"))
|
|
||||||
.default;
|
|
||||||
tokenStore.setTokens(parsed.token, parsed.refreshToken);
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
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(
|
console.log(
|
||||||
"[AuthContext] 📝 Chamando setUser com:",
|
"[AuthContext] 📝 Chamando setUser com:",
|
||||||
@ -242,6 +245,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AuthContext] ❌ Erro ao restaurar sessão:", 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 {
|
} finally {
|
||||||
console.log(
|
console.log(
|
||||||
"[AuthContext] 🏁 Finalizando restauração, setLoading(false)"
|
"[AuthContext] 🏁 Finalizando restauração, setLoading(false)"
|
||||||
@ -319,21 +326,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
const buildSessionUser = React.useCallback(
|
const buildSessionUser = React.useCallback(
|
||||||
(info: UserInfoFullResponse): SessionUser => {
|
(info: UserInfoFullResponse): SessionUser => {
|
||||||
console.log(
|
// ⚠️ SEGURANÇA: Nunca logar tokens ou dados sensíveis em produção
|
||||||
"[buildSessionUser] info recebido:",
|
|
||||||
JSON.stringify(info, null, 2)
|
|
||||||
);
|
|
||||||
const rolesNormalized = (info.roles || [])
|
const rolesNormalized = (info.roles || [])
|
||||||
.map(normalizeRole)
|
.map(normalizeRole)
|
||||||
.filter(Boolean) as UserRole[];
|
.filter(Boolean) as UserRole[];
|
||||||
console.log("[buildSessionUser] roles normalizadas:", rolesNormalized);
|
|
||||||
const permissions = info.permissions || {};
|
const permissions = info.permissions || {};
|
||||||
const primaryRole = pickPrimaryRole(
|
const primaryRole = pickPrimaryRole(
|
||||||
rolesNormalized.length
|
rolesNormalized.length
|
||||||
? rolesNormalized
|
? rolesNormalized
|
||||||
: [normalizeRole((info.roles || [])[0]) || "paciente"]
|
: [normalizeRole((info.roles || [])[0]) || "paciente"]
|
||||||
);
|
);
|
||||||
console.log("[buildSessionUser] primaryRole escolhida:", primaryRole);
|
|
||||||
const base = {
|
const base = {
|
||||||
id: info.user?.id || "",
|
id: info.user?.id || "",
|
||||||
nome:
|
nome:
|
||||||
@ -345,7 +347,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
roles: rolesNormalized,
|
roles: rolesNormalized,
|
||||||
permissions,
|
permissions,
|
||||||
} as SessionUserBase;
|
} as SessionUserBase;
|
||||||
console.log("[buildSessionUser] SessionUser final:", base);
|
|
||||||
if (primaryRole === "medico") {
|
if (primaryRole === "medico") {
|
||||||
return { ...base, role: "medico" } as MedicoUser;
|
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)
|
// LEGADO: usa service de médicos sem validar senha real (apenas existência)
|
||||||
const loginMedico = useCallback(
|
const loginMedico = useCallback(
|
||||||
async (email: string, senha: string) => {
|
async (email: string, senha: string) => {
|
||||||
const resp = await medicoService.loginMedico(email, senha);
|
const resp = await doctorService.loginMedico(email, senha);
|
||||||
if (!resp.success || !resp.data) {
|
if (!resp.success || !resp.data) {
|
||||||
toast.error(resp.error || "Erro ao autenticar médico");
|
toast.error(resp.error || "Erro ao autenticar médico");
|
||||||
return false;
|
return false;
|
||||||
@ -414,45 +415,41 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
[persist]
|
[persist]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fluxo unificado real usando authService + endpoint user-info para mapear role dinâmica
|
// Fluxo unificado real usando authService
|
||||||
const loginComEmailSenha = useCallback(
|
const loginComEmailSenha = useCallback(
|
||||||
async (email: string, senha: string) => {
|
async (email: string, senha: string) => {
|
||||||
console.log("[AuthContext] Iniciando login para:", email);
|
try {
|
||||||
const loginResp = await authService.login({ email, password: senha });
|
const loginResp = await authService.login({ email, password: senha });
|
||||||
console.log("[AuthContext] Resposta login:", loginResp);
|
|
||||||
|
|
||||||
if (!loginResp.success || !loginResp.data) {
|
// Fetch full user info with roles and permissions
|
||||||
console.error("[AuthContext] Login falhou:", loginResp.error);
|
const userInfo = await userService.getUserInfo();
|
||||||
toast.error(loginResp.error || "Falha no login");
|
|
||||||
|
// 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;
|
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]
|
[persist, buildSessionUser]
|
||||||
);
|
);
|
||||||
@ -496,16 +493,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
console.log("[AuthContext] Iniciando logout...");
|
console.log("[AuthContext] Iniciando logout...");
|
||||||
try {
|
try {
|
||||||
const resp = await authService.logout(); // chama /auth/v1/logout (204 esperado)
|
await authService.logout(); // Returns void on success
|
||||||
if (!resp.success && resp.error) {
|
console.log("[AuthContext] Logout remoto bem-sucedido");
|
||||||
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");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[AuthContext] Erro inesperado ao executar logout remoto",
|
"[AuthContext] Erro ao executar logout remoto (continuando limpeza local)",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -513,14 +505,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
console.log("[AuthContext] Limpando estado local...");
|
console.log("[AuthContext] Limpando estado local...");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
clearPersisted();
|
clearPersisted();
|
||||||
authService.clearLocalAuth();
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem("pacienteLogado");
|
localStorage.removeItem("pacienteLogado");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
console.log("[AuthContext] Logout completo - usuário removido do estado");
|
console.log("[AuthContext] Logout completo - usuário removido do estado");
|
||||||
// Modelo somente Supabase: nenhum token técnico para invalidar
|
|
||||||
}
|
}
|
||||||
}, [clearPersisted]);
|
}, [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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@ -5,15 +7,21 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
background-color: #f8fafc;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode hard fallback (ensure full-page background) */
|
/* Dark mode hard fallback (ensure full-page background) */
|
||||||
html.dark, html.dark body, html.dark #root, html.dark .app-root {
|
html.dark,
|
||||||
background-color: #0f172a !important;
|
html.dark body,
|
||||||
background-image: linear-gradient(to bottom right, #0f172a, #1e293b) !important;
|
html.dark #root,
|
||||||
}
|
html.dark .app-root {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom right,
|
||||||
|
#0f172a,
|
||||||
|
#1e293b
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fontes alternativas acessibilidade */
|
/* Fontes alternativas acessibilidade */
|
||||||
@font-face {
|
@font-face {
|
||||||
@ -32,7 +40,8 @@
|
|||||||
/* Quando a fonte OpenDyslexic não estiver disponível, use um fallback amigável à dislexia (Comic Sans)
|
/* 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 */
|
e aplique ajustes de legibilidade para garantir diferença visual imediata */
|
||||||
html.dyslexic-font body {
|
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;
|
letter-spacing: 0.02em;
|
||||||
word-spacing: 0.04em;
|
word-spacing: 0.04em;
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
@ -189,27 +198,28 @@ html.focus-mode.dark *:focus-visible,
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.a11y-toggle-button:focus-visible {
|
.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 {
|
.a11y-toggle-track {
|
||||||
transition: background-color 0.25s ease, box-shadow 0.25s ease;
|
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 {
|
.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 {
|
.a11y-toggle-thumb {
|
||||||
transition: transform 0.25s ease, background-color 0.25s ease;
|
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"] {
|
.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"] {
|
.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"] {
|
.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 {
|
.a11y-toggle-track[data-active="true"] .a11y-toggle-thumb {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@ -217,15 +227,21 @@ html.focus-mode.dark *:focus-visible,
|
|||||||
.a11y-toggle-status-label {
|
.a11y-toggle-status-label {
|
||||||
font-size: 0.625rem; /* 10px */
|
font-size: 0.625rem; /* 10px */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: .5px;
|
letter-spacing: 0.5px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.dark .a11y-toggle-status-label { color: #94a3b8; }
|
.dark .a11y-toggle-status-label {
|
||||||
.a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label { color: #2563eb; }
|
color: #94a3b8;
|
||||||
.dark .a11y-toggle-track[data-active="true"] + .a11y-toggle-status-label { color: #60a5fa; }
|
}
|
||||||
|
.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 */
|
/* Containers e Cards */
|
||||||
.dark .bg-white {
|
.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 { StrictMode } from "react";
|
||||||
import "./bootstrap/initServiceToken"; // inicializa token técnico (service account)
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
|||||||
@ -13,17 +13,15 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Plus,
|
FileText,
|
||||||
Search,
|
|
||||||
Star,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import consultaService from "../services/consultaService";
|
import { appointmentService, doctorService, reportService } from "../services";
|
||||||
import medicoService from "../services/medicoService";
|
import type { Report } from "../services/reports/types";
|
||||||
import AgendamentoConsulta from "../components/AgendamentoConsulta";
|
import AgendamentoConsulta from "../components/AgendamentoConsulta";
|
||||||
|
|
||||||
interface Consulta {
|
interface Consulta {
|
||||||
@ -38,15 +36,20 @@ interface Consulta {
|
|||||||
resultados?: string;
|
resultados?: string;
|
||||||
prescricoes?: string;
|
prescricoes?: string;
|
||||||
proximaConsulta?: string;
|
proximaConsulta?: string;
|
||||||
|
medicoNome?: string;
|
||||||
|
especialidade?: string;
|
||||||
|
valorConsulta?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Medico {
|
interface Medico {
|
||||||
_id?: string;
|
id: string;
|
||||||
id?: string;
|
|
||||||
nome: string;
|
nome: string;
|
||||||
especialidade: string;
|
especialidade: string;
|
||||||
|
crm: string;
|
||||||
|
foto?: string;
|
||||||
|
email?: string;
|
||||||
|
telefone?: string;
|
||||||
valorConsulta?: number;
|
valorConsulta?: number;
|
||||||
valor_consulta?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AcompanhamentoPaciente: React.FC = () => {
|
const AcompanhamentoPaciente: React.FC = () => {
|
||||||
@ -57,8 +60,12 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
const [consultas, setConsultas] = useState<Consulta[]>([]);
|
const [consultas, setConsultas] = useState<Consulta[]>([]);
|
||||||
const [medicos, setMedicos] = useState<Medico[]>([]);
|
const [medicos, setMedicos] = useState<Medico[]>([]);
|
||||||
|
const [loadingMedicos, setLoadingMedicos] = useState(true);
|
||||||
|
const [selectedMedicoId, setSelectedMedicoId] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
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 pacienteId = user?.id || "";
|
||||||
const pacienteNome = user?.nome || "Paciente";
|
const pacienteNome = user?.nome || "Paciente";
|
||||||
@ -72,43 +79,53 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const fetchConsultas = useCallback(async () => {
|
const fetchConsultas = useCallback(async () => {
|
||||||
if (!pacienteId) return;
|
if (!pacienteId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadingMedicos(true);
|
||||||
try {
|
try {
|
||||||
// Buscar consultas da API
|
// Buscar agendamentos da API
|
||||||
const consultasResp = await consultaService.listarConsultas({
|
const appointments = await appointmentService.list({
|
||||||
paciente_id: pacienteId,
|
patient_id: pacienteId,
|
||||||
|
limit: 50,
|
||||||
|
order: "scheduled_at.desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar médicos
|
// Buscar médicos
|
||||||
const medicosResp = await medicoService.listarMedicos({});
|
const medicosData = await doctorService.list();
|
||||||
if (medicosResp.success && medicosResp.data) {
|
const medicosFormatted: Medico[] = medicosData.map((d) => ({
|
||||||
setMedicos(medicosResp.data.data as Medico[]);
|
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) {
|
// Map appointments to old Consulta format
|
||||||
const consultasData = Array.isArray(consultasResp.data)
|
const consultasAPI: Consulta[] = appointments.map((apt) => ({
|
||||||
? consultasResp.data
|
_id: apt.id,
|
||||||
: consultasResp.data.data || [];
|
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(
|
// Set consultas
|
||||||
consultasData.map((c) => ({
|
setConsultas(consultasAPI);
|
||||||
_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([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setLoadingMedicos(false);
|
||||||
console.error("Erro ao carregar consultas:", error);
|
console.error("Erro ao carregar consultas:", error);
|
||||||
toast.error("Erro ao carregar consultas");
|
toast.error("Erro ao carregar consultas");
|
||||||
setConsultas([]);
|
setConsultas([]);
|
||||||
@ -121,6 +138,34 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
}, [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 getMedicoNome = (medicoId: string) => {
|
||||||
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
|
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
|
||||||
return medico?.nome || "Médico";
|
return medico?.nome || "Médico";
|
||||||
@ -142,8 +187,8 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await consultaService.atualizarConsulta(consultaId, {
|
await appointmentService.update(consultaId, {
|
||||||
status: "cancelada",
|
status: "cancelled",
|
||||||
});
|
});
|
||||||
toast.success("Consulta cancelada com sucesso");
|
toast.success("Consulta cancelada com sucesso");
|
||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
@ -218,10 +263,17 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: "dashboard", label: "Início", icon: Home },
|
{ id: "dashboard", label: "Início", icon: Home },
|
||||||
{ id: "appointments", label: "Minhas Consultas", icon: Calendar },
|
{ id: "appointments", label: "Minhas Consultas", icon: Calendar },
|
||||||
|
{ id: "reports", label: "Meus Laudos", icon: FileText },
|
||||||
{ id: "book", label: "Agendar Consulta", icon: Stethoscope },
|
{ id: "book", label: "Agendar Consulta", icon: Stethoscope },
|
||||||
{ id: "messages", label: "Mensagens", icon: MessageCircle },
|
{ 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: "help", label: "Ajuda", icon: HelpCircle },
|
||||||
{ id: "profile", label: "Meu Perfil", icon: User },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
@ -257,7 +309,9 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === "help") {
|
if (item.isLink && item.path) {
|
||||||
|
navigate(item.path);
|
||||||
|
} else if (item.id === "help") {
|
||||||
navigate("/ajuda");
|
navigate("/ajuda");
|
||||||
} else {
|
} else {
|
||||||
setActiveTab(item.id);
|
setActiveTab(item.id);
|
||||||
@ -326,8 +380,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
consulta: Consulta,
|
consulta: Consulta,
|
||||||
isPast: boolean = false
|
isPast: boolean = false
|
||||||
) => {
|
) => {
|
||||||
const medicoNome = getMedicoNome(consulta.medicoId);
|
// Usar dados da consulta local se disponível, senão buscar pelo ID do médico
|
||||||
const especialidade = getMedicoEspecialidade(consulta.medicoId);
|
const medicoNome = consulta.medicoNome || getMedicoNome(consulta.medicoId);
|
||||||
|
const especialidade =
|
||||||
|
consulta.especialidade || getMedicoEspecialidade(consulta.medicoId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -661,7 +717,11 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Book Appointment Content
|
// Book Appointment Content
|
||||||
const renderBookAppointment = () => <AgendamentoConsulta />;
|
const renderBookAppointment = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AgendamentoConsulta medicos={medicos} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Messages Content
|
// Messages Content
|
||||||
const renderMessages = () => (
|
const renderMessages = () => (
|
||||||
@ -723,12 +783,104 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</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 = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return renderDashboard();
|
return renderDashboard();
|
||||||
case "appointments":
|
case "appointments":
|
||||||
return renderAppointments();
|
return renderAppointments();
|
||||||
|
case "reports":
|
||||||
|
return renderReports();
|
||||||
case "book":
|
case "book":
|
||||||
return renderBookAppointment();
|
return renderBookAppointment();
|
||||||
case "messages":
|
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 consultaService from "../services/consultaService"; // não utilizado após integração com appointmentService
|
||||||
import { appointmentService } from "../services";
|
import { appointmentService } from "../services";
|
||||||
import AvailableSlotsPicker from "../components/agenda/AvailableSlotsPicker";
|
import AvailableSlotsPicker from "../components/agenda/AvailableSlotsPicker";
|
||||||
import medicoService from "../services/medicoService";
|
import { doctorService } from "../services";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format, addDays } from "date-fns";
|
import { format, addDays } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
@ -74,42 +74,13 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
|
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
|
||||||
|
|
||||||
// Verificar se há token disponível
|
const doctors = await doctorService.list({ active: true });
|
||||||
const tokenStore = (await import("../services/tokenStore")).default;
|
console.log("[AgendamentoPaciente] Médicos recebidos:", doctors);
|
||||||
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 response = await medicoService.listarMedicos({ status: "ativo" });
|
const mapped: Medico[] = doctors.map((m: any) => ({
|
||||||
console.log("[AgendamentoPaciente] Resposta da API:", response);
|
_id: m.id,
|
||||||
|
nome: m.full_name,
|
||||||
if (!response.success) {
|
especialidade: m.specialty || "",
|
||||||
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 || "",
|
|
||||||
valorConsulta: 0,
|
valorConsulta: 0,
|
||||||
horarioAtendimento: {},
|
horarioAtendimento: {},
|
||||||
}));
|
}));
|
||||||
@ -118,18 +89,9 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
setMedicos(mapped);
|
setMedicos(mapped);
|
||||||
|
|
||||||
if (mapped.length === 0) {
|
if (mapped.length === 0) {
|
||||||
if (response.error && response.error.includes("404")) {
|
toast.error(
|
||||||
toast.error(
|
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
|
||||||
"⚠️ 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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgendamentoPaciente] Erro ao carregar médicos:", 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`
|
`${agendamento.data}T${agendamento.horario}:00.000Z`
|
||||||
);
|
);
|
||||||
|
|
||||||
await appointmentService.createAppointment({
|
await appointmentService.create({
|
||||||
patient_id: pacienteLogado._id,
|
patient_id: pacienteLogado._id,
|
||||||
doctor_id: agendamento.medicoId,
|
doctor_id: agendamento.medicoId,
|
||||||
scheduled_at: dataHora.toISOString(),
|
scheduled_at: dataHora.toISOString(),
|
||||||
appointment_type: "presencial",
|
notes: agendamento.motivoConsulta,
|
||||||
chief_complaint: agendamento.motivoConsulta,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Consulta agendada com sucesso!");
|
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 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 toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import userService from "../services/userService";
|
import { userService } from "../services";
|
||||||
|
|
||||||
const CadastroMedico: React.FC = () => {
|
const CadastroMedico: React.FC = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
nome: "",
|
nome: "",
|
||||||
email: "",
|
email: "",
|
||||||
senha: "",
|
|
||||||
confirmarSenha: "",
|
|
||||||
especialidade: "",
|
|
||||||
crm: "",
|
|
||||||
telefone: "",
|
telefone: "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -19,199 +15,182 @@ const CadastroMedico: React.FC = () => {
|
|||||||
|
|
||||||
const handleCadastro = async (e: React.FormEvent) => {
|
const handleCadastro = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await userService.createMedico({
|
// Validações básicas
|
||||||
nome: formData.nome,
|
if (!formData.nome.trim()) {
|
||||||
email: formData.email,
|
toast.error("Nome completo é obrigatório");
|
||||||
password: formData.senha,
|
setLoading(false);
|
||||||
telefone: formData.telefone,
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error || "Erro ao cadastrar médico");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success("Cadastro realizado com sucesso!");
|
|
||||||
navigate("/login-medico");
|
if (!formData.email.trim() || !formData.email.includes("@")) {
|
||||||
} catch {
|
toast.error("Email válido é obrigatório");
|
||||||
toast.error("Erro ao cadastrar médico. Tente novamente.");
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex items-center justify-center p-4">
|
<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">
|
||||||
{/* Full-viewport background for this page only */}
|
<div className="max-w-md w-full">
|
||||||
<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="text-center mb-8">
|
<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" />
|
<Stethoscope className="w-8 h-8 text-white" />
|
||||||
</div>
|
</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
|
Cadastro de Médico
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Preencha os dados para cadastrar um novo médico
|
Crie sua conta para acessar o sistema
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label
|
||||||
Nome Completo
|
htmlFor="nome"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Nome Completo *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
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>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
id="nome"
|
||||||
value={formData.senha}
|
type="text"
|
||||||
|
value={formData.nome}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, senha: e.target.value }))
|
setFormData((prev) => ({ ...prev, nome: e.target.value }))
|
||||||
}
|
}
|
||||||
className="form-input"
|
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||||
minLength={6}
|
placeholder="Dr. Seu Nome"
|
||||||
required
|
required
|
||||||
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Confirmar Senha
|
<div>
|
||||||
</label>
|
<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
|
<input
|
||||||
type="password"
|
id="email"
|
||||||
value={formData.confirmarSenha}
|
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) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
confirmarSenha: e.target.value,
|
telefone: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="form-input"
|
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||||
required
|
placeholder="(00) 00000-0000"
|
||||||
|
autoComplete="tel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/login-medico")}
|
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
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
Voltar para o login
|
||||||
<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"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 React, { useState } from "react";
|
||||||
import {
|
import { Mail, Lock, User, Phone, Clipboard, ArrowLeft } from "lucide-react";
|
||||||
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 toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { useNavigate } from "react-router-dom";
|
||||||
// import { ptBR } from 'date-fns/locale' // Removido, não utilizado
|
import { userService } from "../services";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CadastroSecretaria: React.FC = () => {
|
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({
|
const [formData, setFormData] = useState({
|
||||||
nome: "",
|
nome: "",
|
||||||
cpf: "",
|
|
||||||
telefone: "",
|
|
||||||
email: "",
|
email: "",
|
||||||
dataNascimento: "",
|
telefone: "",
|
||||||
altura: "",
|
|
||||||
peso: "",
|
|
||||||
endereco: {
|
|
||||||
rua: "",
|
|
||||||
numero: "",
|
|
||||||
bairro: "",
|
|
||||||
cidade: "",
|
|
||||||
cep: "",
|
|
||||||
},
|
|
||||||
convenio: "",
|
|
||||||
numeroCarteirinha: "",
|
|
||||||
observacoes: "",
|
|
||||||
});
|
});
|
||||||
// Função para carregar pacientes
|
const [loading, setLoading] = useState(false);
|
||||||
const carregarPacientes = async () => {
|
const navigate = useNavigate();
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCadastro = async (e: React.FormEvent) => {
|
||||||
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) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// Validações básicas
|
||||||
|
if (!formData.nome.trim()) {
|
||||||
// NOTE: remote CPF validation removed to avoid false negatives
|
toast.error("Nome completo é obrigatório");
|
||||||
|
setLoading(false);
|
||||||
// NOTE: remote CEP validation removed to avoid false negatives
|
return;
|
||||||
|
|
||||||
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!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Refactor) Criação de secretária via fluxo real se condição atender (mantendo lógica anterior condicional)
|
if (!formData.email.trim() || !formData.email.includes("@")) {
|
||||||
// OBS: Este bloco antes criava secretária mock ao cadastrar um novo paciente.
|
toast.error("Email válido é obrigatório");
|
||||||
// Caso essa associação não faça sentido de negócio, remover todo o bloco abaixo posteriormente.
|
setLoading(false);
|
||||||
if (!editingPaciente && formData.email && formData.nome) {
|
return;
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetForm removido, não existe
|
// Usar create-user (flexível, validações mínimas)
|
||||||
setEditingPaciente(null);
|
await userService.createUser({
|
||||||
setShowForm(false);
|
email: formData.email,
|
||||||
} catch (error) {
|
full_name: formData.nome,
|
||||||
console.error("Erro ao salvar paciente:", error);
|
phone: formData.telefone || null,
|
||||||
toast.error("Erro ao salvar paciente. Tente novamente.");
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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="flex flex-col md:flex-row md:items-center md:justify-between">
|
<div className="max-w-md w-full">
|
||||||
<div>
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<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">
|
||||||
Cadastro de Pacientes
|
<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>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
Gerencie o cadastro de pacientes da clínica
|
Crie sua conta para acessar o sistema
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 border border-transparent dark:border-gray-700 transition-colors">
|
||||||
onClick={() => setShowForm(true)}
|
<form onSubmit={handleCadastro} className="space-y-6" noValidate>
|
||||||
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"
|
<div>
|
||||||
>
|
<label
|
||||||
<UserPlus className="w-5 h-5 mr-2" />
|
htmlFor="nome"
|
||||||
Novo Paciente
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
{editingPaciente ? "Editar Paciente" : "Novo Paciente"}
|
Nome Completo *
|
||||||
</h3>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<input
|
||||||
{/* Nome */}
|
id="nome"
|
||||||
<div>
|
type="text"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
value={formData.nome}
|
||||||
Nome Completo
|
onChange={(e) =>
|
||||||
</label>
|
setFormData((prev) => ({ ...prev, nome: e.target.value }))
|
||||||
<input
|
}
|
||||||
type="text"
|
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||||
value={formData.nome}
|
placeholder="Seu nome completo"
|
||||||
onChange={(e) =>
|
required
|
||||||
setFormData({ ...formData, nome: e.target.value })
|
autoComplete="name"
|
||||||
}
|
/>
|
||||||
className="form-input"
|
</div>
|
||||||
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>
|
|
||||||
</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>
|
||||||
</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 React, { useState, useEffect } from "react";
|
||||||
import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
|
import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { listPatients } from "../services/pacienteService";
|
import { patientService, doctorService, appointmentService } from "../services";
|
||||||
import medicoService from "../services/medicoService";
|
|
||||||
import consultaService from "../services/consultaService";
|
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { i18n } from "../i18n";
|
import { i18n } from "../i18n";
|
||||||
import { telemetry } from "../services/telemetry";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
@ -18,56 +16,81 @@ const Home: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
// Só buscar estatísticas se o usuário estiver autenticado
|
||||||
}, []);
|
if (user) {
|
||||||
|
fetchStats();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|
||||||
const [pacientesResult, medicosResult, consultasResult] =
|
// Silenciar erros 401 (não autenticado) - são esperados na home pública
|
||||||
await Promise.all([
|
const [pacientes, medicos, consultasRaw] = await Promise.all([
|
||||||
listPatients().catch(() => ({ data: [] })),
|
patientService.list().catch((err) => {
|
||||||
medicoService.listarMedicos().catch(() => ({ data: { data: [] } })),
|
if (err.response?.status !== 401)
|
||||||
consultaService
|
console.error("Erro ao buscar pacientes:", err);
|
||||||
.listarConsultas()
|
return [];
|
||||||
.catch(() => ({ data: { data: [] } })),
|
}),
|
||||||
]);
|
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 hoje = new Date().toISOString().split("T")[0];
|
||||||
const consultas = consultasResult.data?.data || [];
|
const consultasHoje = consultas.filter((c) =>
|
||||||
const consultasHoje =
|
c.scheduled_at?.startsWith(hoje)
|
||||||
consultas.filter((consulta) => consulta.data_hora?.startsWith(hoje))
|
).length;
|
||||||
.length || 0;
|
|
||||||
|
|
||||||
const consultasPendentes =
|
const consultasPendentes = consultas.filter(
|
||||||
consultas.filter(
|
(c) => c.status === "requested" || c.status === "confirmed"
|
||||||
(consulta) =>
|
).length;
|
||||||
consulta.status === "agendada" || consulta.status === "confirmada"
|
|
||||||
).length || 0;
|
|
||||||
|
|
||||||
const medicos = medicosResult.data?.data || [];
|
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
totalPacientes: pacientesResult.data?.length || 0,
|
totalPacientes: pacientes.length,
|
||||||
totalMedicos: medicos.length || 0,
|
totalMedicos: medicos.length,
|
||||||
consultasHoje,
|
consultasHoje,
|
||||||
consultasPendentes,
|
consultasPendentes,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erro ao carregar estatísticas:", err);
|
console.error("Erro ao carregar estatísticas:", err);
|
||||||
setError(true);
|
setError(true);
|
||||||
telemetry.trackError("stats_load_error", String(err));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCTA = (action: string, destination: string) => {
|
const handleCTA = (action: string, destination: string) => {
|
||||||
telemetry.trackCTA(action, destination);
|
console.log(`CTA clicked: ${action} -> ${destination}`);
|
||||||
navigate(destination);
|
navigate(destination);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import AvatarInitials from "../components/AvatarInitials";
|
import AvatarInitials from "../components/AvatarInitials";
|
||||||
import { Stethoscope, Mail, Phone, AlertTriangle } from "lucide-react";
|
import { Stethoscope, Mail, Phone, AlertTriangle } from "lucide-react";
|
||||||
import medicoService, { MedicoDetalhado } from "../services/medicoService";
|
import { doctorService } from "../services";
|
||||||
|
|
||||||
const ListaMedicos: React.FC = () => {
|
const ListaMedicos: React.FC = () => {
|
||||||
const [medicos, setMedicos] = useState<MedicoDetalhado[]>([]);
|
const [medicos, setMedicos] = useState<MedicoDetalhado[]>([]);
|
||||||
@ -14,7 +14,7 @@ const ListaMedicos: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await medicoService.listarMedicos({ status: "ativo" });
|
const resp = await doctorService.listarMedicos({ status: "ativo" });
|
||||||
if (!resp.success) {
|
if (!resp.success) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(resp.error || "Falha ao carregar médicos");
|
setError(resp.error || "Falha ao carregar médicos");
|
||||||
|
|||||||
@ -24,12 +24,10 @@ function formatEmail(email?: string) {
|
|||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
import { Users, Mail, Phone } from "lucide-react";
|
import { Users, Mail, Phone } from "lucide-react";
|
||||||
import {
|
import { patientService } from "../services/index";
|
||||||
listPatients,
|
import type { Patient } from "../services/patients/types";
|
||||||
type Paciente as PacienteApi,
|
|
||||||
} from "../services/pacienteService";
|
|
||||||
|
|
||||||
type Paciente = PacienteApi;
|
type Paciente = Patient;
|
||||||
|
|
||||||
const ListaPacientes: React.FC = () => {
|
const ListaPacientes: React.FC = () => {
|
||||||
const [pacientes, setPacientes] = useState<Paciente[]>([]);
|
const [pacientes, setPacientes] = useState<Paciente[]>([]);
|
||||||
@ -41,12 +39,10 @@ const ListaPacientes: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await listPatients();
|
const items = await patientService.list();
|
||||||
const items = resp.data;
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
console.warn(
|
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=',
|
'[ListaPacientes] Nenhum paciente retornado. Verifique se a tabela "patients" possui registros.'
|
||||||
resp.fromCache
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setPacientes(items as Paciente[]);
|
setPacientes(items as Paciente[]);
|
||||||
@ -86,17 +82,11 @@ const ListaPacientes: React.FC = () => {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{paciente.avatar_url ? (
|
<AvatarInitials name={paciente.full_name} size={40} />
|
||||||
<img
|
|
||||||
src={paciente.avatar_url}
|
|
||||||
alt={paciente.nome}
|
|
||||||
className="h-10 w-10 rounded-full object-cover border"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AvatarInitials name={paciente.nome} size={40} />
|
|
||||||
)}
|
|
||||||
<Users className="w-5 h-5 text-blue-600" />
|
<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>
|
||||||
<div className="text-sm text-gray-700">
|
<div className="text-sm text-gray-700">
|
||||||
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
|
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
|
||||||
@ -105,12 +95,13 @@ const ListaPacientes: React.FC = () => {
|
|||||||
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
|
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<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>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Nascimento:{" "}
|
Nascimento:{" "}
|
||||||
{paciente.dataNascimento
|
{paciente.birth_date
|
||||||
? new Date(paciente.dataNascimento).toLocaleDateString()
|
? new Date(paciente.birth_date).toLocaleDateString()
|
||||||
: "Não informado"}
|
: "Não informado"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Mail, Lock, Stethoscope } from "lucide-react";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { authService } from "../services";
|
||||||
|
|
||||||
const LoginMedico: React.FC = () => {
|
const LoginMedico: React.FC = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -14,14 +15,6 @@ const LoginMedico: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loginComEmailSenha } = useAuth();
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -29,31 +22,12 @@ const LoginMedico: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
console.log("[LoginMedico] Fazendo login com email:", formData.email);
|
console.log("[LoginMedico] Fazendo login com email:", formData.email);
|
||||||
|
|
||||||
const authService = (await import("../services/authService")).default;
|
await authService.login({
|
||||||
const loginResult = await authService.login({
|
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.senha,
|
password: formData.senha,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!loginResult.success) {
|
console.log("[LoginMedico] Login bem-sucedido!");
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await loginComEmailSenha(formData.email, formData.senha);
|
const ok = await loginComEmailSenha(formData.email, formData.senha);
|
||||||
|
|
||||||
@ -149,10 +123,15 @@ const LoginMedico: React.FC = () => {
|
|||||||
{loading ? "Entrando..." : "Entrar"}
|
{loading ? "Entrando..." : "Entrar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 text-center">
|
<div className="text-center mt-4">
|
||||||
<strong>{LOCAL_MEDICO.email}</strong> /{" "}
|
<button
|
||||||
<strong>{LOCAL_MEDICO.senha}</strong>
|
type="button"
|
||||||
</p>
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { User, Mail, Lock } from "lucide-react";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { authService, patientService, userService } from "../services";
|
||||||
|
|
||||||
const LoginPaciente: React.FC = () => {
|
const LoginPaciente: React.FC = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -57,14 +58,6 @@ const LoginPaciente: React.FC = () => {
|
|||||||
|
|
||||||
const { loginPaciente } = useAuth();
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -73,61 +66,28 @@ const LoginPaciente: React.FC = () => {
|
|||||||
console.log("[LoginPaciente] Fazendo login com email:", formData.email);
|
console.log("[LoginPaciente] Fazendo login com email:", formData.email);
|
||||||
|
|
||||||
// Fazer login via API Supabase
|
// Fazer login via API Supabase
|
||||||
const authService = (await import("../services/authService")).default;
|
await authService.login({
|
||||||
const loginResult = await authService.login({
|
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.senha,
|
password: formData.senha,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!loginResult.success) {
|
console.log("[LoginPaciente] Login bem-sucedido!");
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar dados do paciente da API
|
// Buscar dados do paciente da API
|
||||||
const { listPatients } = await import("../services/pacienteService");
|
const pacientes = await patientService.list();
|
||||||
const pacientesResult = await listPatients({ search: formData.email });
|
const paciente = pacientes.find((p: any) => p.email === formData.email);
|
||||||
|
|
||||||
console.log(
|
console.log("[LoginPaciente] Paciente encontrado:", paciente);
|
||||||
"[LoginPaciente] Resultado da busca de pacientes:",
|
|
||||||
pacientesResult
|
|
||||||
);
|
|
||||||
|
|
||||||
const paciente = pacientesResult.data?.[0];
|
|
||||||
|
|
||||||
if (paciente) {
|
if (paciente) {
|
||||||
console.log("[LoginPaciente] Paciente encontrado:", {
|
console.log("[LoginPaciente] Paciente encontrado:", {
|
||||||
id: paciente.id,
|
id: paciente.id,
|
||||||
nome: paciente.nome,
|
nome: paciente.full_name,
|
||||||
email: paciente.email,
|
email: paciente.email,
|
||||||
});
|
});
|
||||||
const ok = await loginPaciente({
|
const ok = await loginPaciente({
|
||||||
id: paciente.id,
|
id: paciente.id,
|
||||||
nome: paciente.nome,
|
nome: paciente.full_name,
|
||||||
email: paciente.email,
|
email: paciente.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -154,8 +114,82 @@ const LoginPaciente: React.FC = () => {
|
|||||||
|
|
||||||
const handleCadastro = async (e: React.FormEvent) => {
|
const handleCadastro = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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
|
// Login LOCAL: cria uma sessão de paciente sem chamar a API
|
||||||
@ -169,69 +203,25 @@ const LoginPaciente: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fazer login via API Supabase
|
// Fazer login via API Supabase
|
||||||
const authService = (await import("../services/authService")).default;
|
await authService.login({
|
||||||
const loginResult = await authService.login({
|
|
||||||
email: email,
|
email: email,
|
||||||
password: senha,
|
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!");
|
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
|
// Buscar dados do paciente da API
|
||||||
const { listPatients } = await import("../services/pacienteService");
|
const pacientes = await patientService.list();
|
||||||
const pacientesResult = await listPatients({ search: email });
|
const paciente = pacientes.find((p: any) => p.email === email);
|
||||||
|
|
||||||
const paciente = pacientesResult.data?.[0];
|
|
||||||
|
|
||||||
if (paciente) {
|
if (paciente) {
|
||||||
console.log(
|
console.log(
|
||||||
"[LoginPaciente] Paciente encontrado na API:",
|
"[LoginPaciente] Paciente encontrado na API:",
|
||||||
paciente.nome
|
paciente.full_name
|
||||||
);
|
);
|
||||||
const ok = await loginPaciente({
|
const ok = await loginPaciente({
|
||||||
id: paciente.id,
|
id: paciente.id,
|
||||||
nome: paciente.nome,
|
nome: paciente.full_name,
|
||||||
email: paciente.email,
|
email: paciente.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -363,10 +353,16 @@ const LoginPaciente: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{loading ? "Entrando..." : "Entrar"}
|
{loading ? "Entrando..." : "Entrar"}
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 text-center">
|
|
||||||
<strong>{LOCAL_PATIENT.email}</strong> /{" "}
|
<div className="text-center mt-4">
|
||||||
<strong>{LOCAL_PATIENT.senha}</strong>
|
<button
|
||||||
</p>
|
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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
/* Formulário de Cadastro */
|
/* Formulário de Cadastro */
|
||||||
@ -563,72 +559,11 @@ const LoginPaciente: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4">
|
||||||
<div>
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<label
|
ℹ️ Após o cadastro, você receberá um email com link para
|
||||||
htmlFor="cad_senha"
|
ativar sua conta e definir sua senha.
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
</p>
|
||||||
>
|
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -637,7 +572,7 @@ const LoginPaciente: React.FC = () => {
|
|||||||
htmlFor="cad_telefone"
|
htmlFor="cad_telefone"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Telefone
|
Telefone Celular *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="cad_telefone"
|
id="cad_telefone"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Mail, Lock, Clipboard } from "lucide-react";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { authService } from "../services";
|
||||||
|
|
||||||
const LoginSecretaria: React.FC = () => {
|
const LoginSecretaria: React.FC = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -14,14 +15,6 @@ const LoginSecretaria: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loginComEmailSenha } = useAuth();
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -29,31 +22,12 @@ const LoginSecretaria: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
console.log("[LoginSecretaria] Fazendo login com email:", formData.email);
|
console.log("[LoginSecretaria] Fazendo login com email:", formData.email);
|
||||||
|
|
||||||
const authService = (await import("../services/authService")).default;
|
await authService.login({
|
||||||
const loginResult = await authService.login({
|
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.senha,
|
password: formData.senha,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!loginResult.success) {
|
console.log("[LoginSecretaria] Login bem-sucedido!");
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await loginComEmailSenha(formData.email, formData.senha);
|
const ok = await loginComEmailSenha(formData.email, formData.senha);
|
||||||
|
|
||||||
@ -148,11 +122,6 @@ const LoginSecretaria: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{loading ? "Entrando..." : "Entrar"}
|
{loading ? "Entrando..." : "Entrar"}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Shield,
|
Shield,
|
||||||
X,
|
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@ -18,29 +17,21 @@ import toast from "react-hot-toast";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
createPatient,
|
patientService,
|
||||||
listPatients,
|
type Patient,
|
||||||
updatePatient,
|
doctorService,
|
||||||
deletePatient,
|
|
||||||
type Paciente,
|
|
||||||
} from "../services/pacienteService";
|
|
||||||
import {
|
|
||||||
createDoctor,
|
|
||||||
listDoctors,
|
|
||||||
updateDoctor,
|
|
||||||
deleteDoctor,
|
|
||||||
type Doctor,
|
type Doctor,
|
||||||
} from "../services/doctorService";
|
userService,
|
||||||
import {
|
type UserInfo,
|
||||||
createUser,
|
type UserRole,
|
||||||
type CreateUserInput,
|
type CreateUserInput,
|
||||||
type RoleType,
|
profileService,
|
||||||
} from "../services/adminService";
|
} from "../services";
|
||||||
import adminUserService, {
|
import type { CrmUF } from "../services/doctors/types";
|
||||||
FullUserInfo,
|
|
||||||
UpdateUserData,
|
// Type aliases para compatibilidade
|
||||||
UserRole,
|
type Paciente = Patient;
|
||||||
} from "../services/adminUserService";
|
type FullUserInfo = UserInfo;
|
||||||
|
|
||||||
type TabType = "pacientes" | "usuarios" | "medicos";
|
type TabType = "pacientes" | "usuarios" | "medicos";
|
||||||
|
|
||||||
@ -79,14 +70,18 @@ const PainelAdmin: React.FC = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [showUserModal, setShowUserModal] = useState(false);
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<FullUserInfo | null>(null);
|
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] =
|
const [managingRolesUser, setManagingRolesUser] =
|
||||||
useState<FullUserInfo | null>(null);
|
useState<FullUserInfo | null>(null);
|
||||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||||
const [newRole, setNewRole] = useState<string>("");
|
const [newRole, setNewRole] = useState<UserRole>("user");
|
||||||
const [formUser, setFormUser] = useState<CreateUserInput>({
|
const [formUser, setFormUser] = useState<CreateUserInput>({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
|
||||||
full_name: "",
|
full_name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -141,14 +136,59 @@ const PainelAdmin: React.FC = () => {
|
|||||||
const loadUsuarios = async () => {
|
const loadUsuarios = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await adminUserService.listAllUsers();
|
// Lista todos os perfis (usuários) do sistema
|
||||||
if (result.success && result.data) {
|
const profiles = await profileService.list();
|
||||||
setUsuarios(result.data);
|
|
||||||
} else {
|
// Busca todas as roles de uma vez
|
||||||
toast.error(result.error || "Erro ao carregar usuários");
|
const allRoles = await userService.listRoles();
|
||||||
}
|
|
||||||
} catch {
|
// 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");
|
toast.error("Erro ao carregar usuários");
|
||||||
|
setUsuarios([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -157,10 +197,8 @@ const PainelAdmin: React.FC = () => {
|
|||||||
const loadPacientes = async () => {
|
const loadPacientes = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await listPatients({ per_page: 100 });
|
const patients = await patientService.list();
|
||||||
if ("data" in response) {
|
setPacientes(patients);
|
||||||
setPacientes(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar pacientes:", error);
|
console.error("Erro ao carregar pacientes:", error);
|
||||||
toast.error("Erro ao carregar pacientes");
|
toast.error("Erro ao carregar pacientes");
|
||||||
@ -172,10 +210,8 @@ const PainelAdmin: React.FC = () => {
|
|||||||
const loadMedicos = async () => {
|
const loadMedicos = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await listDoctors();
|
const doctors = await doctorService.list();
|
||||||
if (response.success && response.data) {
|
setMedicos(doctors);
|
||||||
setMedicos(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar médicos:", error);
|
console.error("Erro ao carregar médicos:", error);
|
||||||
toast.error("Erro ao carregar médicos");
|
toast.error("Erro ao carregar médicos");
|
||||||
@ -189,15 +225,12 @@ const PainelAdmin: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await createUser(formUser);
|
// isPublicRegistration = false porque é admin criando
|
||||||
|
const newUser = await userService.createUser(formUser, false);
|
||||||
if (response.success) {
|
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`);
|
||||||
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`);
|
setShowUserModal(false);
|
||||||
setShowUserModal(false);
|
resetFormUser();
|
||||||
resetFormUser();
|
loadUsuarios();
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao criar usuário");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao criar usuário:", error);
|
console.error("Erro ao criar usuário:", error);
|
||||||
toast.error("Erro ao criar usuário");
|
toast.error("Erro ao criar usuário");
|
||||||
@ -207,79 +240,113 @@ const PainelAdmin: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Funções de gerenciamento de usuários
|
// Funções de gerenciamento de usuários
|
||||||
const handleEditUser = (user: FullUserInfo) => {
|
// TODO: Implement admin user endpoints (update, enable/disable, delete)
|
||||||
setEditingUser(user);
|
const handleEditUser = (_user: FullUserInfo) => {
|
||||||
setEditForm({
|
toast.error("Função de edição de usuário ainda não implementada");
|
||||||
full_name: user.profile?.full_name || "",
|
// setEditingUser(user);
|
||||||
email: user.profile?.email || "",
|
// setEditForm({
|
||||||
phone: user.profile?.phone || "",
|
// full_name: user.profile?.full_name || "",
|
||||||
disabled: user.profile?.disabled || false,
|
// email: user.profile?.email || "",
|
||||||
});
|
// phone: user.profile?.phone || "",
|
||||||
|
// disabled: user.profile?.disabled || false,
|
||||||
|
// });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEditUser = async () => {
|
const handleSaveEditUser = async () => {
|
||||||
if (!editingUser) return;
|
toast.error("Função de salvar usuário ainda não implementada");
|
||||||
|
// TODO: Implement adminUserService.updateUser endpoint
|
||||||
try {
|
// if (!editingUser) return;
|
||||||
const result = await adminUserService.updateUser(
|
// try {
|
||||||
editingUser.user.id,
|
// const result = await adminUserService.updateUser(editingUser.user.id, editForm);
|
||||||
editForm
|
// if (result.success) {
|
||||||
);
|
// toast.success("Usuário atualizado com sucesso!");
|
||||||
if (result.success) {
|
// setEditingUser(null);
|
||||||
toast.success("Usuário atualizado com sucesso!");
|
// loadUsuarios();
|
||||||
setEditingUser(null);
|
// }
|
||||||
loadUsuarios();
|
// } catch {
|
||||||
} else {
|
// toast.error("Erro ao atualizar usuário");
|
||||||
toast.error(result.error || "Erro ao atualizar usuário");
|
// }
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Erro ao atualizar usuário");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleStatusUser = async (
|
const handleToggleStatusUser = async (
|
||||||
userId: string,
|
_userId: string,
|
||||||
currentStatus: boolean
|
_currentStatus: boolean
|
||||||
) => {
|
) => {
|
||||||
try {
|
toast.error(
|
||||||
const result = currentStatus
|
"Função de habilitar/desabilitar usuário ainda não implementada"
|
||||||
? await adminUserService.enableUser(userId)
|
);
|
||||||
: await adminUserService.disableUser(userId);
|
// 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) {
|
const handleDeleteUser = async (_userId: string, _userName: string) => {
|
||||||
toast.success(
|
toast.error("Função de deletar usuário ainda não implementada");
|
||||||
`Usuário ${
|
// TODO: Implement adminUserService.deleteUser endpoint
|
||||||
currentStatus ? "habilitado" : "desabilitado"
|
// if (!confirm(`Tem certeza que deseja deletar o usuário "${userName}"?`)) return;
|
||||||
} com sucesso!`
|
// try {
|
||||||
);
|
// const result = await adminUserService.deleteUser(userId);
|
||||||
loadUsuarios();
|
// if (result.success) {
|
||||||
} else {
|
// toast.success("Usuário deletado com sucesso!");
|
||||||
toast.error(result.error || "Erro ao alterar status do usuário");
|
// 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 {
|
} catch (error) {
|
||||||
toast.error("Erro ao alterar status do usuário");
|
console.error("Erro ao adicionar role:", error);
|
||||||
|
toast.error("Erro ao adicionar role");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: string, userName: string) => {
|
const handleRemoveRole = async (role: UserRole) => {
|
||||||
if (
|
if (!managingRolesUser) return;
|
||||||
!confirm(
|
|
||||||
`Tem certeza que deseja deletar o usuário "${userName}"? Esta ação não pode ser desfeita.`
|
if (!confirm(`Tem certeza que deseja remover a role "${role}"?`)) return;
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await adminUserService.deleteUser(userId);
|
await userService.removeUserRole(managingRolesUser.user.id, role);
|
||||||
if (result.success) {
|
toast.success(`Role "${role}" removida com sucesso!`);
|
||||||
toast.success("Usuário deletado com sucesso!");
|
await loadUsuarios();
|
||||||
loadUsuarios();
|
|
||||||
} else {
|
// Atualiza o estado local do modal
|
||||||
toast.error(result.error || "Erro ao deletar usuário");
|
const updatedUsers = usuarios.find(
|
||||||
|
(u) => u.user.id === managingRolesUser.user.id
|
||||||
|
);
|
||||||
|
if (updatedUsers) {
|
||||||
|
setManagingRolesUser(updatedUsers);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Erro ao deletar usuário");
|
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) => {
|
const handleEditPaciente = (paciente: Paciente) => {
|
||||||
setEditingPaciente(paciente);
|
setEditingPaciente(paciente);
|
||||||
setFormPaciente({
|
setFormPaciente({
|
||||||
full_name: paciente.nome,
|
full_name: paciente.full_name,
|
||||||
cpf: paciente.cpf || "",
|
cpf: paciente.cpf || "",
|
||||||
email: paciente.email || "",
|
email: paciente.email || "",
|
||||||
phone_mobile: paciente.telefone || "",
|
phone_mobile: paciente.phone_mobile || "",
|
||||||
birth_date: paciente.dataNascimento || "",
|
birth_date: paciente.birth_date || "",
|
||||||
social_name: paciente.socialName || "",
|
social_name: paciente.social_name || "",
|
||||||
sex: paciente.sexo || "",
|
sex: paciente.sex || "",
|
||||||
blood_type: paciente.tipoSanguineo || "",
|
blood_type: paciente.blood_type || "",
|
||||||
weight_kg: paciente.pesoKg?.toString() || "",
|
weight_kg: paciente.weight_kg?.toString() || "",
|
||||||
height_m: paciente.alturaM?.toString() || "",
|
height_m: paciente.height_m?.toString() || "",
|
||||||
street: paciente.endereco?.rua || "",
|
street: paciente.street || "",
|
||||||
number: paciente.endereco?.numero || "",
|
number: paciente.number || "",
|
||||||
complement: paciente.endereco?.complemento || "",
|
complement: paciente.complement || "",
|
||||||
neighborhood: paciente.endereco?.bairro || "",
|
neighborhood: paciente.neighborhood || "",
|
||||||
city: paciente.endereco?.cidade || "",
|
city: paciente.city || "",
|
||||||
state: paciente.endereco?.estado || "",
|
state: paciente.state || "",
|
||||||
cep: paciente.endereco?.cep || "",
|
cep: paciente.cep || "",
|
||||||
});
|
});
|
||||||
setShowPacienteModal(true);
|
setShowPacienteModal(true);
|
||||||
};
|
};
|
||||||
@ -313,53 +380,58 @@ const PainelAdmin: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = {
|
const patientData = {
|
||||||
nome: formPaciente.full_name,
|
full_name: formPaciente.full_name,
|
||||||
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
|
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
|
||||||
email: formPaciente.email,
|
email: formPaciente.email,
|
||||||
telefone: formPaciente.phone_mobile,
|
phone_mobile: formPaciente.phone_mobile,
|
||||||
dataNascimento: formPaciente.birth_date,
|
birth_date: formPaciente.birth_date,
|
||||||
socialName: formPaciente.social_name,
|
social_name: formPaciente.social_name,
|
||||||
sexo: formPaciente.sex,
|
sex: formPaciente.sex,
|
||||||
tipoSanguineo: formPaciente.blood_type,
|
blood_type: formPaciente.blood_type,
|
||||||
pesoKg: formPaciente.weight_kg
|
weight_kg: formPaciente.weight_kg
|
||||||
? parseFloat(formPaciente.weight_kg)
|
? parseFloat(formPaciente.weight_kg)
|
||||||
: undefined,
|
: undefined,
|
||||||
alturaM: formPaciente.height_m
|
height_m: formPaciente.height_m
|
||||||
? parseFloat(formPaciente.height_m)
|
? parseFloat(formPaciente.height_m)
|
||||||
: undefined,
|
: undefined,
|
||||||
endereco: {
|
street: formPaciente.street,
|
||||||
rua: formPaciente.street,
|
number: formPaciente.number,
|
||||||
numero: formPaciente.number,
|
complement: formPaciente.complement,
|
||||||
complemento: formPaciente.complement,
|
neighborhood: formPaciente.neighborhood,
|
||||||
bairro: formPaciente.neighborhood,
|
city: formPaciente.city,
|
||||||
cidade: formPaciente.city,
|
state: formPaciente.state,
|
||||||
estado: formPaciente.state,
|
cep: formPaciente.cep,
|
||||||
cep: formPaciente.cep,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingPaciente) {
|
if (editingPaciente) {
|
||||||
const response = await updatePatient(editingPaciente.id, data);
|
await patientService.update(editingPaciente.id, patientData);
|
||||||
if (response.success) {
|
toast.success("Paciente atualizado com sucesso!");
|
||||||
toast.success("Paciente atualizado com sucesso!");
|
setShowPacienteModal(false);
|
||||||
setShowPacienteModal(false);
|
setEditingPaciente(null);
|
||||||
setEditingPaciente(null);
|
resetFormPaciente();
|
||||||
resetFormPaciente();
|
loadPacientes();
|
||||||
loadPacientes();
|
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao atualizar paciente");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const response = await createPatient(data);
|
// Usar create-user com create_patient_record=true (nova API 21/10)
|
||||||
if (response.success) {
|
// isPublicRegistration = false porque é admin criando
|
||||||
toast.success("Paciente criado com sucesso!");
|
await userService.createUser(
|
||||||
setShowPacienteModal(false);
|
{
|
||||||
resetFormPaciente();
|
email: patientData.email,
|
||||||
loadPacientes();
|
full_name: patientData.full_name,
|
||||||
} else {
|
phone: patientData.phone_mobile,
|
||||||
toast.error(response.error || "Erro ao criar paciente");
|
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) {
|
} catch (error) {
|
||||||
console.error("Erro ao salvar paciente:", error);
|
console.error("Erro ao salvar paciente:", error);
|
||||||
@ -380,18 +452,10 @@ const PainelAdmin: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
|
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
|
||||||
|
await patientService.delete(id);
|
||||||
const response = await deletePatient(id);
|
console.log("[PainelAdmin] Paciente deletado com sucesso");
|
||||||
|
toast.success("Paciente deletado com sucesso!");
|
||||||
console.log("[PainelAdmin] Resultado da deleção:", response);
|
loadPacientes();
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
|
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
|
||||||
toast.error("Erro ao deletar paciente");
|
toast.error("Erro ao deletar paciente");
|
||||||
@ -436,26 +500,42 @@ const PainelAdmin: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (editingMedico) {
|
if (editingMedico) {
|
||||||
const response = await updateDoctor(editingMedico.id!, medicoData);
|
await doctorService.update(editingMedico.id!, medicoData);
|
||||||
if (response.success) {
|
toast.success("Médico atualizado com sucesso!");
|
||||||
toast.success("Médico atualizado com sucesso!");
|
setShowMedicoModal(false);
|
||||||
setShowMedicoModal(false);
|
setEditingMedico(null);
|
||||||
setEditingMedico(null);
|
resetFormMedico();
|
||||||
resetFormMedico();
|
loadMedicos();
|
||||||
loadMedicos();
|
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao atualizar médico");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const response = await createDoctor(medicoData);
|
// Usar create-user com role=medico (nova API 21/10 - create-doctor não cria auth user)
|
||||||
if (response.success) {
|
// isPublicRegistration = false porque é admin criando
|
||||||
toast.success("Médico criado com sucesso!");
|
await userService.createUser(
|
||||||
setShowMedicoModal(false);
|
{
|
||||||
resetFormMedico();
|
email: medicoData.email,
|
||||||
loadMedicos();
|
full_name: medicoData.full_name,
|
||||||
} else {
|
phone: medicoData.phone_mobile,
|
||||||
toast.error(response.error || "Erro ao criar médico");
|
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) {
|
} catch (error) {
|
||||||
console.error("Erro ao salvar médico:", error);
|
console.error("Erro ao salvar médico:", error);
|
||||||
@ -475,13 +555,9 @@ const PainelAdmin: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await deleteDoctor(id);
|
await doctorService.delete(id);
|
||||||
if (response.success) {
|
toast.success("Médico deletado com sucesso!");
|
||||||
toast.success("Médico deletado com sucesso!");
|
loadMedicos();
|
||||||
loadMedicos();
|
|
||||||
} else {
|
|
||||||
toast.error(response.error || "Erro ao deletar médico");
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Erro ao deletar médico");
|
toast.error("Erro ao deletar médico");
|
||||||
}
|
}
|
||||||
@ -512,7 +588,6 @@ const PainelAdmin: React.FC = () => {
|
|||||||
const resetFormUser = () => {
|
const resetFormUser = () => {
|
||||||
setFormUser({
|
setFormUser({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
|
||||||
full_name: "",
|
full_name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -572,12 +647,13 @@ const PainelAdmin: React.FC = () => {
|
|||||||
"TO",
|
"TO",
|
||||||
];
|
];
|
||||||
|
|
||||||
const availableRoles: RoleType[] = [
|
const availableRoles: UserRole[] = [
|
||||||
"admin",
|
"admin",
|
||||||
"gestor",
|
"gestor",
|
||||||
"medico",
|
"medico",
|
||||||
"secretaria",
|
"secretaria",
|
||||||
"user",
|
"user",
|
||||||
|
"paciente",
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -688,12 +764,14 @@ const PainelAdmin: React.FC = () => {
|
|||||||
} hover:bg-gray-100`}
|
} hover:bg-gray-100`}
|
||||||
>
|
>
|
||||||
<div>
|
<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 className="text-gray-600 text-sm">
|
||||||
{p.email} | {p.telefone}
|
{p.email} | {p.phone_mobile}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-xs">
|
<p className="text-gray-500 text-xs">
|
||||||
CPF: {p.cpf} | Nascimento: {p.dataNascimento}
|
CPF: {p.cpf} | Nascimento: {p.birth_date}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -705,7 +783,9 @@ const PainelAdmin: React.FC = () => {
|
|||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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"
|
title="Deletar"
|
||||||
>
|
>
|
||||||
@ -1144,6 +1224,15 @@ const PainelAdmin: React.FC = () => {
|
|||||||
placeholder="(00) 00000-0000"
|
placeholder="(00) 00000-0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Data de Nascimento
|
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"
|
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>
|
||||||
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Telefone
|
Telefone
|
||||||
@ -1312,7 +1386,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormUser({
|
setFormUser({
|
||||||
...formUser,
|
...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"
|
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"
|
placeholder="(00) 00000-0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Data de Nascimento
|
Data de Nascimento
|
||||||
@ -1661,45 +1744,31 @@ const PainelAdmin: React.FC = () => {
|
|||||||
Roles Atuais:
|
Roles Atuais:
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{userRoles.length > 0 ? (
|
{managingRolesUser &&
|
||||||
userRoles.map((userRole) => (
|
managingRolesUser.roles &&
|
||||||
|
managingRolesUser.roles.length > 0 ? (
|
||||||
|
managingRolesUser.roles.map((role, index) => (
|
||||||
<div
|
<div
|
||||||
key={userRole.id}
|
key={`${role}-${index}`}
|
||||||
className={`flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold ${
|
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"
|
? "bg-purple-100 text-purple-700"
|
||||||
: userRole.role === "gestor"
|
: role === "gestor"
|
||||||
? "bg-blue-100 text-blue-700"
|
? "bg-blue-100 text-blue-700"
|
||||||
: userRole.role === "medico"
|
: role === "medico"
|
||||||
? "bg-indigo-100 text-indigo-700"
|
? "bg-indigo-100 text-indigo-700"
|
||||||
: userRole.role === "secretaria"
|
: role === "secretaria"
|
||||||
? "bg-green-100 text-green-700"
|
? "bg-green-100 text-green-700"
|
||||||
: "bg-gray-100 text-gray-700"
|
: "bg-gray-100 text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{userRole.role}
|
{role}
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => handleRemoveRole(role)}
|
||||||
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");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hover:bg-black hover:bg-opacity-10 rounded-full p-0.5"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -1719,49 +1788,19 @@ const PainelAdmin: React.FC = () => {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={newRole}
|
value={newRole}
|
||||||
onChange={(e) => setNewRole(e.target.value)}
|
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-600 focus:border-purple-600/40 text-sm"
|
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="user">User</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="paciente">Paciente</option>
|
||||||
<option value="gestor">Gestor</option>
|
<option value="secretaria">Secretaria</option>
|
||||||
<option value="medico">Médico</option>
|
<option value="medico">Médico</option>
|
||||||
<option value="secretaria">Secretária</option>
|
<option value="gestor">Gestor</option>
|
||||||
<option value="user">Usuário</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={handleAddRole}
|
||||||
if (!newRole) {
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Adicionar
|
Adicionar
|
||||||
@ -1773,8 +1812,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setManagingRolesUser(null);
|
setManagingRolesUser(null);
|
||||||
setUserRoles([]);
|
setNewRole("user");
|
||||||
setNewRole("");
|
|
||||||
}}
|
}}
|
||||||
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"
|
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,
|
Plus,
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import consultasService, { Consulta as ServiceConsulta } from "../services/consultasService";
|
import {
|
||||||
import { listPatients } from "../services/pacienteService";
|
appointmentService,
|
||||||
|
patientService,
|
||||||
|
reportService,
|
||||||
|
type Appointment,
|
||||||
|
type Patient,
|
||||||
|
type CreateReportInput,
|
||||||
|
} from "../services";
|
||||||
|
import type { Report } from "../services/reports/types";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import relatorioService, {
|
|
||||||
RelatorioCreate,
|
|
||||||
} from "../services/relatorioService";
|
|
||||||
import DisponibilidadeMedico from "../components/DisponibilidadeMedico";
|
import DisponibilidadeMedico from "../components/DisponibilidadeMedico";
|
||||||
import ConsultaModal from "../components/consultas/ConsultaModal";
|
import ConsultaModal from "../components/consultas/ConsultaModal";
|
||||||
|
import { AvatarUpload } from "../components/ui/AvatarUpload";
|
||||||
|
|
||||||
|
// Type aliases para compatibilidade
|
||||||
|
type ServiceConsulta = Appointment;
|
||||||
|
type RelatorioCreate = CreateReportInput;
|
||||||
|
|
||||||
interface ConsultaUI {
|
interface ConsultaUI {
|
||||||
id: string;
|
id: string;
|
||||||
@ -44,8 +53,6 @@ interface ConsultaUI {
|
|||||||
observacoes?: string;
|
observacoes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const PainelMedico: React.FC = () => {
|
const PainelMedico: React.FC = () => {
|
||||||
const { user, roles, logout } = useAuth();
|
const { user, roles, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -58,70 +65,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
roles.includes("admin"));
|
roles.includes("admin"));
|
||||||
const medicoId = temAcessoMedico ? user.id : "";
|
const medicoId = temAcessoMedico ? user.id : "";
|
||||||
const medicoNome = user?.nome || "Médico";
|
const medicoNome = user?.nome || "Médico";
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user?.avatar_url || null);
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
@ -132,6 +76,8 @@ const PainelMedico: React.FC = () => {
|
|||||||
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
||||||
const [relatorioModalOpen, setRelatorioModalOpen] = useState(false);
|
const [relatorioModalOpen, setRelatorioModalOpen] = useState(false);
|
||||||
const [loadingRelatorio, setLoadingRelatorio] = useState(false);
|
const [loadingRelatorio, setLoadingRelatorio] = useState(false);
|
||||||
|
const [laudos, setLaudos] = useState<Report[]>([]);
|
||||||
|
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
||||||
const [pacientesDisponiveis, setPacientesDisponiveis] = useState<
|
const [pacientesDisponiveis, setPacientesDisponiveis] = useState<
|
||||||
Array<{ id: string; nome: string }>
|
Array<{ id: string; nome: string }>
|
||||||
>([]);
|
>([]);
|
||||||
@ -157,39 +103,38 @@ const PainelMedico: React.FC = () => {
|
|||||||
const fetchConsultas = useCallback(async () => {
|
const fetchConsultas = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let resp;
|
let appointments;
|
||||||
if (user?.role === "admin" || roles.includes("admin")) {
|
if (user?.role === "admin" || roles.includes("admin")) {
|
||||||
// Admin: busca todas as consultas do sistema
|
// Admin: busca todas as consultas do sistema
|
||||||
resp = await consultasService.listarTodas();
|
appointments = await appointmentService.list();
|
||||||
} else {
|
} else {
|
||||||
// Médico comum: busca todas as consultas do próprio médico
|
// Médico comum: busca todas as consultas do próprio médico
|
||||||
if (!medicoId) return;
|
if (!medicoId) return;
|
||||||
resp = await consultasService.listarPorMedico(medicoId);
|
appointments = await appointmentService.list({ doctor_id: medicoId });
|
||||||
}
|
}
|
||||||
if (resp && resp.success && resp.data) {
|
if (appointments && appointments.length > 0) {
|
||||||
// Buscar nomes dos pacientes usando getPatientById
|
// Buscar nomes dos pacientes
|
||||||
const { getPatientById } = await import("../services/pacienteService");
|
|
||||||
const consultasComNomes = await Promise.all(
|
const consultasComNomes = await Promise.all(
|
||||||
resp.data.map(async (c) => {
|
appointments.map(async (appt: Appointment) => {
|
||||||
let pacienteNome = "Paciente Desconhecido";
|
let pacienteNome = "Paciente Desconhecido";
|
||||||
try {
|
try {
|
||||||
const pacienteResp = await getPatientById(c.pacienteId);
|
const patient = await patientService.getById(appt.patient_id);
|
||||||
if (pacienteResp.success && pacienteResp.data) {
|
if (patient) {
|
||||||
pacienteNome = pacienteResp.data.nome;
|
pacienteNome = patient.full_name;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao buscar nome do paciente:", error);
|
console.error("Erro ao buscar nome do paciente:", error);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: appt.id,
|
||||||
pacienteId: c.pacienteId,
|
pacienteId: appt.patient_id,
|
||||||
medicoId: c.medicoId,
|
medicoId: appt.doctor_id,
|
||||||
pacienteNome,
|
pacienteNome,
|
||||||
medicoNome: medicoNome,
|
medicoNome: medicoNome,
|
||||||
dataHora: c.dataHora,
|
dataHora: appt.scheduled_at,
|
||||||
status: c.status,
|
status: appt.status,
|
||||||
tipo: c.tipo,
|
tipo: appt.appointment_type,
|
||||||
observacoes: c.observacoes,
|
observacoes: appt.notes || undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -206,20 +151,46 @@ const PainelMedico: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [user, roles, medicoId, medicoNome]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
}, [fetchConsultas]);
|
}, [fetchConsultas]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "reports") {
|
||||||
|
fetchLaudos();
|
||||||
|
}
|
||||||
|
}, [activeTab, fetchLaudos]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (relatorioModalOpen && user?.id) {
|
if (relatorioModalOpen && user?.id) {
|
||||||
const carregarPacientes = async () => {
|
const carregarPacientes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await listPatients({ per_page: 200 });
|
const patients = await patientService.list();
|
||||||
if ("data" in response) {
|
if (patients && patients.length > 0) {
|
||||||
setPacientesDisponiveis(
|
setPacientesDisponiveis(
|
||||||
response.data.map((p) => ({
|
patients.map((p: Patient) => ({
|
||||||
id: p.id,
|
id: p.id || "",
|
||||||
nome: p.nome,
|
nome: p.full_name,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -246,20 +217,19 @@ const PainelMedico: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const payload: RelatorioCreate = {
|
const payload: RelatorioCreate = {
|
||||||
patient_id: formRelatorio.patient_id,
|
patient_id: formRelatorio.patient_id,
|
||||||
order_number: formRelatorio.order_number || "",
|
|
||||||
exam: formRelatorio.exam,
|
exam: formRelatorio.exam,
|
||||||
diagnosis: formRelatorio.diagnosis || "",
|
diagnosis: formRelatorio.diagnosis || undefined,
|
||||||
conclusion: formRelatorio.conclusion || "",
|
conclusion: formRelatorio.conclusion || undefined,
|
||||||
cid_code: formRelatorio.cid_code || "",
|
cid_code: formRelatorio.cid_code || undefined,
|
||||||
content_html: formRelatorio.content_html || "",
|
content_html: formRelatorio.content_html || undefined,
|
||||||
status: formRelatorio.status,
|
status: formRelatorio.status,
|
||||||
requested_by: formRelatorio.requested_by || medicoNome,
|
requested_by: formRelatorio.requested_by || medicoNome,
|
||||||
due_at: formRelatorio.due_at || "",
|
due_at: formRelatorio.due_at || undefined,
|
||||||
hide_date: formRelatorio.hide_date,
|
hide_date: formRelatorio.hide_date,
|
||||||
hide_signature: formRelatorio.hide_signature,
|
hide_signature: formRelatorio.hide_signature,
|
||||||
};
|
};
|
||||||
const resp = await relatorioService.criarRelatorio(payload);
|
const newReport = await reportService.create(payload);
|
||||||
if (resp.success) {
|
if (newReport) {
|
||||||
toast.success("Relatório criado com sucesso!");
|
toast.success("Relatório criado com sucesso!");
|
||||||
setRelatorioModalOpen(false);
|
setRelatorioModalOpen(false);
|
||||||
setFormRelatorio({
|
setFormRelatorio({
|
||||||
@ -277,7 +247,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
hide_signature: false,
|
hide_signature: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(resp.error || "Erro ao criar relatório");
|
toast.error("Erro ao criar relatório");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao criar relatório:", error);
|
console.error("Erro ao criar relatório:", error);
|
||||||
@ -394,6 +364,13 @@ const PainelMedico: React.FC = () => {
|
|||||||
{ id: "appointments", label: "Consultas", icon: Clock },
|
{ id: "appointments", label: "Consultas", icon: Clock },
|
||||||
{ id: "availability", label: "Disponibilidade", icon: Calendar },
|
{ id: "availability", label: "Disponibilidade", icon: Calendar },
|
||||||
{ id: "reports", label: "Relatórios", icon: FileText },
|
{ 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: "help", label: "Ajuda", icon: HelpCircle },
|
||||||
{ id: "settings", label: "Configurações", icon: Settings },
|
{ id: "settings", label: "Configurações", icon: Settings },
|
||||||
];
|
];
|
||||||
@ -403,54 +380,15 @@ const PainelMedico: React.FC = () => {
|
|||||||
{/* Doctor Profile */}
|
{/* Doctor Profile */}
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative group">
|
<AvatarUpload
|
||||||
{avatarUrl ? (
|
userId={user?.id}
|
||||||
<img
|
currentAvatarUrl={avatarUrl}
|
||||||
src={avatarUrl}
|
name={medicoNome}
|
||||||
alt="Avatar"
|
color="green"
|
||||||
className="h-14 w-14 rounded-full object-cover border shadow"
|
size="lg"
|
||||||
/>
|
editable={true}
|
||||||
) : (
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
{medicoNome}
|
{medicoNome}
|
||||||
@ -470,7 +408,9 @@ const PainelMedico: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === "help") {
|
if (item.isLink && item.path) {
|
||||||
|
navigate(item.path);
|
||||||
|
} else if (item.id === "help") {
|
||||||
navigate("/ajuda");
|
navigate("/ajuda");
|
||||||
} else {
|
} else {
|
||||||
setActiveTab(item.id);
|
setActiveTab(item.id);
|
||||||
@ -845,22 +785,96 @@ const PainelMedico: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Relatórios
|
Meus Laudos
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRelatorioModalOpen(true)}
|
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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Novo Relatório
|
Novo Laudo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||||
<div className="p-6">
|
{loadingLaudos ? (
|
||||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
<div className="p-6">
|
||||||
Funcionalidade em desenvolvimento
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||||
</p>
|
Carregando laudos...
|
||||||
</div>
|
</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>
|
||||||
</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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { consultasService, type Consulta } from "../services/consultasService";
|
|
||||||
import {
|
import {
|
||||||
getPatientById,
|
appointmentService,
|
||||||
listPatientAttachments,
|
type Appointment,
|
||||||
addPatientAttachment,
|
patientService,
|
||||||
removePatientAttachment,
|
type Patient as PacienteServiceModel,
|
||||||
type Paciente as PacienteServiceModel,
|
} from "../services";
|
||||||
type Anexo,
|
|
||||||
} from "../services/pacienteService";
|
// Legacy type for compatibility
|
||||||
|
type Consulta = Appointment;
|
||||||
|
type Anexo = {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
tipo: string;
|
||||||
|
tamanho: number;
|
||||||
|
url: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface ExtendedPacienteMeta {
|
interface ExtendedPacienteMeta {
|
||||||
rg?: string;
|
rg?: string;
|
||||||
@ -53,13 +61,10 @@ const ProntuarioPaciente = () => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const respPaciente = await getPatientById(id);
|
const patient = await patientService.getById(id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (respPaciente.success && respPaciente.data) {
|
setPaciente(patient);
|
||||||
setPaciente(respPaciente.data);
|
|
||||||
} else {
|
|
||||||
throw new Error(respPaciente.error || "Paciente não encontrado");
|
|
||||||
}
|
|
||||||
// metadata local
|
// metadata local
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("pacientes_meta") || "{}";
|
const raw = localStorage.getItem("pacientes_meta") || "{}";
|
||||||
@ -71,19 +76,17 @@ const ProntuarioPaciente = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
setMeta(null);
|
setMeta(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// consultas (últimas + futuras limitadas)
|
// consultas (últimas + futuras limitadas)
|
||||||
const respConsultas = await consultasService.listarPorPaciente(id, {
|
const appointments = await appointmentService.list({
|
||||||
|
patient_id: id,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
order: "scheduled_at.desc",
|
||||||
});
|
});
|
||||||
if (respConsultas.success && respConsultas.data)
|
setConsultas(appointments);
|
||||||
setConsultas(respConsultas.data);
|
|
||||||
// anexos
|
// anexos (placeholder - not yet implemented in backend)
|
||||||
try {
|
setAnexos([]);
|
||||||
const anexosList = await listPatientAttachments(id);
|
|
||||||
setAnexos(anexosList);
|
|
||||||
} catch {
|
|
||||||
console.warn("Falha ao carregar anexos");
|
|
||||||
}
|
|
||||||
// histórico (placeholder - poderá ser alimentado quando audit trail existir)
|
// histórico (placeholder - poderá ser alimentado quando audit trail existir)
|
||||||
const histRaw = localStorage.getItem(`paciente_hist_${id}`) || "[]";
|
const histRaw = localStorage.getItem(`paciente_hist_${id}`) || "[]";
|
||||||
try {
|
try {
|
||||||
@ -105,24 +108,28 @@ const ProntuarioPaciente = () => {
|
|||||||
}, [id, navigate]);
|
}, [id, navigate]);
|
||||||
|
|
||||||
const consultasOrdenadas = useMemo(() => {
|
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]);
|
}, [consultas]);
|
||||||
|
|
||||||
const ultimaConsulta = consultasOrdenadas.find(() => true);
|
const ultimaConsulta = consultasOrdenadas.find(() => true);
|
||||||
const proximaConsulta = useMemo(() => {
|
const proximaConsulta = useMemo(() => {
|
||||||
const agora = new Date().toISOString();
|
const agora = new Date().toISOString();
|
||||||
return consultasOrdenadas
|
return consultasOrdenadas
|
||||||
.filter((c) => c.dataHora >= agora)
|
.filter((c) => (c.scheduled_at || "") >= agora)
|
||||||
.sort((a, b) => a.dataHora.localeCompare(b.dataHora))[0];
|
.sort((a, b) =>
|
||||||
|
(a.scheduled_at || "").localeCompare(b.scheduled_at || "")
|
||||||
|
)[0];
|
||||||
}, [consultasOrdenadas]);
|
}, [consultasOrdenadas]);
|
||||||
|
|
||||||
const idade = useMemo(() => {
|
const idade = useMemo(() => {
|
||||||
if (!paciente?.dataNascimento) return null;
|
if (!paciente?.birth_date) return null;
|
||||||
const d = new Date(paciente.dataNascimento);
|
const d = new Date(paciente.birth_date);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
const diff = Date.now() - d.getTime();
|
const diff = Date.now() - d.getTime();
|
||||||
return Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));
|
return Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));
|
||||||
}, [paciente?.dataNascimento]);
|
}, [paciente?.birth_date]);
|
||||||
|
|
||||||
const handleUpload = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@ -130,11 +137,8 @@ const ProntuarioPaciente = () => {
|
|||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
for (const file of Array.from(files)) {
|
// Attachment upload not yet implemented in backend
|
||||||
const anexo = await addPatientAttachment(id, file);
|
toast("Funcionalidade de anexos em desenvolvimento");
|
||||||
setAnexos((a) => [...a, anexo]);
|
|
||||||
}
|
|
||||||
toast.success("Anexo(s) enviado(s) com sucesso");
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Falha ao enviar anexo");
|
toast.error("Falha ao enviar anexo");
|
||||||
} finally {
|
} finally {
|
||||||
@ -147,9 +151,8 @@ const ProntuarioPaciente = () => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!confirm(`Remover anexo "${anexo.nome || anexo.id}"?`)) return;
|
if (!confirm(`Remover anexo "${anexo.nome || anexo.id}"?`)) return;
|
||||||
try {
|
try {
|
||||||
await removePatientAttachment(id, anexo.id);
|
// Attachment deletion not yet implemented in backend
|
||||||
setAnexos((as) => as.filter((a) => a.id !== anexo.id));
|
toast("Funcionalidade de anexos em desenvolvimento");
|
||||||
toast.success("Anexo removido");
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Erro ao remover anexo");
|
toast.error("Erro ao remover anexo");
|
||||||
}
|
}
|
||||||
@ -207,7 +210,7 @@ const ProntuarioPaciente = () => {
|
|||||||
← Voltar
|
← Voltar
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-2xl font-semibold mt-1">
|
<h1 className="text-2xl font-semibold mt-1">
|
||||||
Prontuário: {paciente.nome}
|
Prontuário: {paciente.full_name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
CPF: {paciente.cpf || "—"} {idade ? `• ${idade} anos` : ""}
|
CPF: {paciente.cpf || "—"} {idade ? `• ${idade} anos` : ""}
|
||||||
@ -229,34 +232,33 @@ const ProntuarioPaciente = () => {
|
|||||||
<h2 className="font-semibold mb-3">Visão Geral</h2>
|
<h2 className="font-semibold mb-3">Visão Geral</h2>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
<li>
|
<li>
|
||||||
Última consulta: {formatDataHora(ultimaConsulta?.dataHora)}
|
Última consulta: {formatDataHora(ultimaConsulta?.scheduled_at)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Próxima consulta: {formatDataHora(proximaConsulta?.dataHora)}
|
Próxima consulta:{" "}
|
||||||
|
{formatDataHora(proximaConsulta?.scheduled_at)}
|
||||||
</li>
|
</li>
|
||||||
<li>Convênio: {paciente.convenio || "Particular"}</li>
|
<li>Convênio: Particular</li>
|
||||||
<li>VIP: {paciente.vip ? "Sim" : "Não"}</li>
|
<li>VIP: Não</li>
|
||||||
<li>Tipo sanguíneo: {paciente.tipoSanguineo || "—"}</li>
|
<li>Tipo sanguíneo: {paciente.blood_type || "—"}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white rounded-xl shadow border border-gray-200">
|
<div className="p-4 bg-white rounded-xl shadow border border-gray-200">
|
||||||
<h2 className="font-semibold mb-3">Contato</h2>
|
<h2 className="font-semibold mb-3">Contato</h2>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
<li>Email: {paciente.email || "—"}</li>
|
<li>Email: {paciente.email || "—"}</li>
|
||||||
<li>Telefone: {paciente.telefone || "—"}</li>
|
<li>Telefone: {paciente.phone_mobile || "—"}</li>
|
||||||
<li>
|
<li>
|
||||||
Endereço:{" "}
|
Endereço:{" "}
|
||||||
{paciente.endereco?.rua
|
{paciente.street
|
||||||
? `${paciente.endereco.rua}, ${
|
? `${paciente.street}, ${paciente.number || "s/n"} - ${
|
||||||
paciente.endereco.numero || "s/n"
|
paciente.neighborhood || ""
|
||||||
} - ${paciente.endereco.bairro || ""}`
|
}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Cidade/UF: {paciente.endereco?.cidade || ""}
|
Cidade/UF: {paciente.city || ""}
|
||||||
{paciente.endereco?.estado
|
{paciente.state ? `/${paciente.state}` : ""}
|
||||||
? `/${paciente.endereco.estado}`
|
|
||||||
: ""}
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -325,7 +327,7 @@ const ProntuarioPaciente = () => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{formatDataHora(c.dataHora)} {c.tipo && `• ${c.tipo}`}
|
{formatDataHora(c.scheduled_at)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">Status: {c.status}</p>
|
<p className="text-gray-500">Status: {c.status}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -370,7 +372,7 @@ const ProntuarioPaciente = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{a.nome || a.id}</p>
|
<p className="font-medium">{a.nome || a.id}</p>
|
||||||
<p className="text-gray-500 text-xs">
|
<p className="text-gray-500 text-xs">
|
||||||
{a.tipo || a.categoria || "arquivo"}{" "}
|
{a.tipo || "arquivo"}{" "}
|
||||||
{a.tamanho ? `• ${(a.tamanho / 1024).toFixed(1)} KB` : ""}
|
{a.tamanho ? `• ${(a.tamanho / 1024).toFixed(1)} KB` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 React, { useEffect, useState } from "react";
|
||||||
import { decodeJwt } from "../services/tokenDebug";
|
import { authService } from "../services";
|
||||||
import authService from "../services/authService";
|
|
||||||
|
// 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 {
|
interface DecodedInfo {
|
||||||
exp?: number;
|
exp?: number;
|
||||||
@ -37,16 +48,20 @@ const TokenInspector: React.FC = () => {
|
|||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const resp = await authService.refreshToken();
|
try {
|
||||||
if (!resp.success) setError(resp.error || "Falha ao renovar");
|
await authService.refreshToken();
|
||||||
load();
|
load();
|
||||||
setRefreshing(false);
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Falha ao renovar");
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: DecodedInfo | undefined = decoded?.payload;
|
const payload: DecodedInfo | undefined = decoded;
|
||||||
const expired = decoded?.expired;
|
|
||||||
const exp = payload?.exp;
|
const exp = payload?.exp;
|
||||||
const iat = payload?.iat;
|
const iat = payload?.iat;
|
||||||
|
const expired = exp ? Date.now() > exp * 1000 : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<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