Merge pull request 'branch final' (#1) from finalbranch into main

Reviewed-on: #1
This commit is contained in:
gabriel 2025-12-05 00:52:04 +00:00
commit 6c8a1a3541
116 changed files with 30899 additions and 4076 deletions

19
.env Normal file
View File

@ -0,0 +1,19 @@
# Supabase Configuration
VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ
# API Configuration
VITE_API_URL=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=
# App Configuration
VITE_APP_NAME=MedConnect
VITE_ENVIRONMENT=development
#Chat configuration
VITE_CHAT_SERVICE_URL=https://sxnbrchqhzednsuvjegd.supabase.co
VITE_CHAT_ANO_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN4bmJyY2hxaHplZG5zdXZqZWdkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQ2Mzc0NjQsImV4cCI6MjA4MDIxMzQ2NH0.AQHfpaTsKXUYc1Tv3NqFagWP9Ok1R9pQy7v3tHa_WmM
VITE_SERVICE_KEY= service_u248bv3
VITE_TEMPLATE_KEY= template_v51obqa
VITE_PUBLIC_KEY= QDDGCfWv5_DlZGisG

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/assets/css/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -24,6 +24,7 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react/prop-types': 'off',
},
},
])

View File

@ -1,13 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediConnect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<html lang="pt-br">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediConnect</title>
<!-- Link para google fonts icon-->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,1,0" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</body>
</html>

7932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,24 +10,40 @@
"preview": "vite preview"
},
"dependencies": {
"20": "^3.1.9",
"22": "^0.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fortawesome/fontawesome-free": "^7.0.0",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@supabase/supabase-js": "^2.57.0",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@supabase/supabase-js": "^2.76.1",
"@tailwindcss/vite": "^4.1.14",
"@tiptap/extension-image": "^3.4.2",
"@tiptap/pm": "^3.4.2",
"@tiptap/react": "^3.4.2",
"@tiptap/starter-kit": "^3.4.2",
"@zegocloud/zego-uikit-prebuilt": "^2.17.0",
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"emailjs-com": "^3.2.0",
"lucide-react": "^0.544.0",
"react": "^19.1.1",
"react-bootstrap": "^2.10.10",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.1.1",
"react-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^7.8.2",
"recharts": "^3.1.2",
"react-select": "^5.10.2",
"recharts": "^3.3.0",
"sweetalert2": "^11.23.0",
"use-mask-input": "^3.5.0"
},
"devDependencies": {
@ -35,6 +51,7 @@
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"autoprefix": "^1.0.1",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",

View File

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 396 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

BIN
public/img/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/img/consultion.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
public/img/logo50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,17 +0,0 @@
// src/App.jsx
import './assets/css/index.css'
import Navbar from './components/Navbar'
import Sidebar from './components/Sidebar'
import { Outlet } from 'react-router-dom'
function App() {
return (
<div>
<Navbar />
<Sidebar />
<Outlet />
</div>
)
}
export default App

242
src/Company.js Normal file
View File

@ -0,0 +1,242 @@
export const Company = `
Introdução:
Olá! Sou o assistente virtual da MediConnect, criado para guiar pacientes, médicos, secretarias e administradores em nossa clínica digital.
Posso ajudar você a entender como agendar suas consultas, visualizar exames e laudos, gerenciar agendas e esclarecer qualquer dúvida sobre as funcionalidades do sistema.
Seja bem-vindo à MediConnect: saúde moderna, organizada e digital.
=============================== Sobre a MediConnect:
A MediConnect é uma plataforma completa de gestão de clínicas e consultórios.
Nosso objetivo é integrar todos os atores da saúde em um único ambiente:
Pacientes
Médicos
Secretarias
Administradores
Com ela, você consegue centralizar consultas, exames, laudos, relatórios e históricos em um sistema seguro, prático e acessível de qualquer lugar.
=============================== Principais funcionalidades:
Marcar e Gerenciar Consultas
Listar Pacientes Cadastrados ("listar pacientes")
Listar Médicos Cadastrados ("listar médicos")
Verificar Horários Disponíveis de Médicos ("listar horários médicos")
Dashboard Interativo por Perfil
Emissão de Laudos Digitais
Acesso a Exames e Histórico Clínico
Relatórios Administrativos
Notificações e lembretes automáticos
=============================== Localização & Contato:
Endereço: 123 Saúde Avenue, São Paulo SP, Brasil
Atendimento presencial:
Segunda a Sexta: 7h00 às 20h00
Sábados: 8h00 às 14h00
Domingos e feriados: fechado
Telefone para marcar consultas e dúvidas: +55 (79) 99942-2715
E-mail: contato@mediconnect.com
Site oficial: https://mediconnect-neon.vercel.app
=============================== Perfis disponíveis e funções:
Paciente ( Melhorado)
Acessa o painel Minhas Consultas para ver suas consultas confirmadas.
Visualiza e baixa resultados em Meus Exames.
Consulta o histórico de Meus Laudos médicos emitidos.
Importante: Para marcar, remarcar ou cancelar uma consulta, o paciente deve entrar em contato com a nossa secretaria por telefone.
Exemplos de perguntas:
"Como faço para agendar uma consulta?"
"Onde vejo os resultados dos meus exames?"
"Meus laudos ficam disponíveis no sistema?"
Médico - Gerencia sua agenda em tempo real.
Consulta compromissos confirmados e pendentes.
Atende pacientes de forma organizada.
Emite e disponibiliza laudos digitais.
Acompanha exames realizados pelos pacientes.
Visualiza histórico clínico de seus pacientes.
Exemplos de perguntas:
"Como acessar minha agenda?"
"Posso emitir laudos pelo sistema?"
"Onde vejo exames de meus pacientes?"
Secretaria - Cadastra novos pacientes.
Acesso somente a lista de medicos
Marca, confirma e remarca consultas para pacientes.
suporte administrativo ao médico e ao paciente.
Gerencia as agendas e o fluxo de atendimentos.
Exemplos de perguntas:
"A secretaria pode cadastrar novos médicos?"
"Como agendar uma consulta para um paciente?"
Administrador - Possui acesso total ao sistema.
Gerencia permissões de usuários.
Controla cadastros de médicos e pacientes.
Gera relatórios e estatísticas da clínica.
Supervisiona agendas médicas e fluxo de consultas.
Exemplos de perguntas:
"O que faz o administrador?"
"Quem pode alterar permissões?"
"Quais relatórios a plataforma oferece?"
=============================== Principais funcionalidades:
Marcar Consultas ( Melhorado): A secretaria marca consultas de forma rápida e digital, e o paciente visualiza a confirmação em seu painel.
Remarcar/Cancelar Consultas: Flexibilidade para remarcar compromissos através do contato com a secretaria.
Dashboard Interativo: Cada perfil possui um painel com informações personalizadas.
Agenda Médica: Médicos e secretarias podem visualizar e organizar compromissos.
CRUD de Pacientes e Médicos: Cadastro, atualização e gerenciamento completo.
Laudos Digitais: Médicos emitem documentos eletrônicos, acessíveis ao paciente.
Exames: Pacientes acessam resultados em Meus Exames.
Histórico Clínico: Registros permanentes de consultas, exames e laudos.
Notificações Automáticas: Lembretes de consultas e avisos de novos resultados.
Relatórios Administrativos: Estatísticas para gestores da clínica.
=============================== Fluxos explicativos (passo a passo):
Como um paciente marca uma consulta: ( Novo)
Entre em contato com a clínica pelo telefone: +55 (11) 4000-1234.
Informe seus dados e o médico/especialidade desejada.
A secretaria irá verificar os horários disponíveis e confirmar o agendamento.
Após a confirmação, a consulta aparecerá automaticamente no seu painel, em Minhas Consultas.
Como um médico emite um laudo: - Acesse no painel laudo selecione o paciente atendido.
Insira as informações médicas no campo de laudo.
Salve e finalize.
O laudo ficará disponível em Meus Laudos no painel do paciente.
Como a secretaria cadastra um paciente: - até o painel administrativo Pacientes.
Clique em "Novo Paciente".
Preencha dados pessoais, contato e observações.
Salve para liberar o acesso ao paciente.
=============================== Respostas rápidas (FAQ do bot):
( Melhorado e Reorganizado)
"Como marcar uma consulta?"
Para marcar uma consulta, você deve ligar para nossa secretaria no número +55 (11) 4000-1234. Nossa equipe fará o agendamento e ele aparecerá no seu portal.
"Posso cancelar ou remarcar uma consulta?"
Sim, para cancelar ou remarcar, basta entrar em contato com a secretaria com antecedência.
"Onde vejo meus exames e laudos?"
Você pode acessá-los a qualquer momento nos painéis Meus Exames e Meus Laudos, na sua área de paciente.
"Quais são os perfis da plataforma?"
São quatro: Paciente, Médico, Secretaria e Administrador, cada um com suas permissões.
"Quem pode cadastrar novos pacientes ou médicos?"
Apenas os perfis de Secretaria e Administrador.
"O médico consegue ver meu histórico?"
Sim, para garantir a qualidade do seu atendimento, os médicos têm acesso ao histórico clínico dos pacientes que atendem.
"Recebo lembretes sobre minhas consultas?"
Sim! O sistema envia lembretes automáticos para você não perder seus compromissos.
=============================== Diferenciais MediConnect:
Integração completa entre pacientes, médicos e administração.
Atendimento digital seguro, rápido e acessível.
Agilidade na entrega de laudos e exames online.
Dashboard moderno e fácil de usar.
Redução de falhas administrativas com um sistema centralizado.
Transparência e organização na comunicação clínica.
=============================== Quando não souber responder:
Se o usuário fizer uma pergunta fora do contexto da MediConnect
ou que não tenha relação com saúde, clínica, consultas, laudos,
ou sistema interno, siga estas instruções:
1. Seja educado e transparente.
2. Não invente informações.
3. Diga algo como:
"Desculpe 😅, mas essa pergunta foge um pouco do que posso responder.
Posso te ajudar com informações sobre a MediConnect!"
=============================== Políticas gerais (resumidas):
Todos os dados de pacientes são armazenados com segurança e sigilo.
Apenas perfis autorizados podem acessar laudos e exames.
Consultas devem ser canceladas ou remarcadas com no mínimo 24h de antecedência.
O administrador é responsável por revisar permissões e manter a ordem do sistema.
=============================== Mensagem final:
Na MediConnect, acreditamos que a saúde deve ser prática, organizada e digital.
Nosso sistema foi feito para facilitar a rotina de todos.
Sempre que precisar, pode contar comigo para esclarecer suas dúvidas!
`;

View File

@ -5,6 +5,20 @@ const supabaseUrl = "https://pxhmxgotbfwypaqwpcmh.supabase.co"
const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB4aG14Z290YmZ3eXBhcXdwY21oIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY1NjU2MjAsImV4cCI6MjA3MjE0MTYyMH0.Yu2C0MZ-f4EaFGeJ03YmDtT7m539Q84JfqULqwe2XUI"
const supabase = createClient(supabaseUrl, supabaseAnonKey)
export async function logoutUser() {
const { error } = await supabase.auth.signOut();
if (error) {
console.error("Erro ao fazer logout:", error.message);
return false;
}
// Remove token do localStorage (caso você use)
localStorage.removeItem("access_token");
return true;
}
export default supabase

View File

@ -0,0 +1,167 @@
* Conteúdo geral */
.sdc-content {
padding: 20px;
font-family: "Roboto", sans-serif;
background-color: #f5f6fa;
}
/* Widgets */
.sdc-dash-widget {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
min-height: 110px;
}
.sdc-dash-widget span {
font-size: 30px;
padding: 15px;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
width: 60px;
height: 60px;
}
.sdc-dash-widget-bg1 { background-color: #2196f3; }
.sdc-dash-widget-bg2 { background-color: #4caf50; }
.sdc-dash-widget-bg3 { background-color: #ff9800; }
.sdc-dash-widget-bg4 { background-color: #e91e63; }
.sdc-dash-widget-info {
flex: 1;
text-align: left;
}
.sdc-dash-widget-info h3 {
margin: 0;
font-size: 28px;
color: #333;
}
.sdc-dash-widget-info span {
display: block;
font-size: 16px;
color: #777;
margin-top: 5px;
}
/* Cards */
.sdc-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 20px;
padding: 15px;
}
.sdc-card-header h4 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
display: inline-block;
}
.sdc-btn {
padding: 6px 12px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.sdc-btn-primary { background-color: #2196f3; color: #fff; }
.sdc-btn-outline-primary {
background-color: transparent;
border: 1px solid #2196f3;
color: #2196f3;
}
/* Gráfico de pizza */
.sdc-pie-chart-wrapper {
width: 200px;
height: 200px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 30px auto;
position: relative;
background: conic-gradient(
#4caf50 0% 40%,
#2196f3 40% 70%,
#ff9800 70% 90%,
#e91e63 90% 100%
);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sdc-pie-chart-label {
position: absolute;
text-align: center;
font-size: 16px;
font-weight: 600;
color: #fff;
line-height: 1.4;
}
/* Gráfico de colunas */
.sdc-bar-chart-wrapper {
display: flex;
justify-content: space-around;
align-items: flex-end;
margin: 30px 0;
height: 200px;
}
.sdc-bar {
width: 20%;
background-color: #2196f3;
position: relative;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: center;
align-items: flex-end;
transition: 0.3s ease;
}
.sdc-bar span {
color: #fff;
font-weight: 600;
margin-bottom: 5px;
}
.sdc-bar-red { background-color: #e91e63; }
.sdc-bar-orange { background-color: #ff9800; }
.sdc-bar-green { background-color: #4caf50; }
/* Responsivo */
@media (max-width: 768px) {
.sdc-dash-widget {
flex-direction: column;
text-align: center;
}
.sdc-dash-widget span {
margin-bottom: 10px;
margin-right: 0;
}
.sdc-bar-chart-wrapper {
flex-direction: column;
height: auto;
}
.sdc-bar {
width: 80%;
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,167 @@
/* Conteúdo geral */
.doc-content {
padding: 20px;
font-family: "Roboto", sans-serif;
background-color: #f5f6fa;
}
/* Widgets */
.doc-dash-widget {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
min-height: 110px;
}
.doc-dash-widget span {
font-size: 30px;
padding: 15px;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
width: 60px;
height: 60px;
}
.doc-dash-widget-bg1 { background-color: #2196f3; }
.doc-dash-widget-bg2 { background-color: #4caf50; }
.doc-dash-widget-bg3 { background-color: #ff9800; }
.doc-dash-widget-bg4 { background-color: #e91e63; }
.doc-dash-widget-info {
flex: 1;
text-align: left;
}
.doc-dash-widget-info h3 {
margin: 0;
font-size: 28px;
color: #333;
}
.doc-dash-widget-info span {
display: block;
font-size: 16px;
color: #777;
margin-top: 5px;
}
/* Cards */
.doc-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 20px;
padding: 15px;
}
.doc-card-header h4 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
display: inline-block;
}
.doc-btn {
padding: 6px 12px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.doc-btn-primary { background-color: #2196f3; color: #fff; }
.doc-btn-outline-primary {
background-color: transparent;
border: 1px solid #2196f3;
color: #2196f3;
}
/* Gráfico de pizza */
.doc-pie-chart-wrapper {
width: 200px;
height: 200px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 30px auto;
position: relative;
background: conic-gradient(
#4caf50 5% 40%,
#ff9800 40% 80%,
#e91e63 50% 80%
);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.doc-pie-chart-label {
position: absolute;
text-align: center;
font-size: 16px;
font-weight: 600;
color: #fff;
line-height: 1.4;
}
/* Gráfico de colunas */
.doc-bar-chart-wrapper {
display: flex;
justify-content: space-around;
align-items: flex-end;
margin: 30px 0;
height: 200px;
}
.doc-bar {
width: 20%;
position: relative;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: center;
align-items: flex-end;
transition: 0.3s ease;
}
.doc-bar span {
color: #fff;
font-weight: 600;
margin-bottom: 5px;
}
.doc-bar-red { background-color: #e91e63; }
.doc-bar-orange { background-color: #ff9800; }
.doc-bar-green { background-color: #4caf50; }
/* Responsivo */
@media (max-width: 768px) {
.doc-dash-widget {
flex-direction: column;
text-align: center;
}
.doc-dash-widget span {
margin-bottom: 10px;
margin-right: 0;
}
.doc-bar-chart-wrapper {
flex-direction: column;
height: auto;
}
.doc-bar {
width: 80%;
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,63 @@
.dashboard-container {
padding: 2rem;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
min-height: 100vh;
}
.dashboard-title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 1.5rem;
text-align: center;
}
.cards-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
text-align: center;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 12px rgba(0,0,0,0.1);
}
.card-title {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.card-subtitle {
color: #555;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.card-button {
width: 100%;
padding: 0.5rem;
background-color: #1976d2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
.card-button:hover {
background-color: #115293;
}

266
src/assets/css/chatbot.css Normal file
View File

@ -0,0 +1,266 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
*{
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Inter", sans-serif;
}
.body-gpt {
width: 100%;
min-height: 100vh;
background-color: linear-gradient(#F4F0FF, #DACDFF);
}
#chatbot-toggler{
position: fixed;
bottom: 30px;
right:35px;
border:none;
height: 50px;
width: 50px;
display: flex;
cursor: pointer;
border-radius: 50%;
background: linear-gradient(135deg, #004a99, #0077cc);
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
#chatbot-toggler span{
position: absolute;
color: #fff;
}
.container-chatbox.show-chatbot #chatbot-toggler span{
transform: rotate(90deg);
}
#chatbot-toggler span:last-child,
.container-chatbox.show-chatbot #chatbot-toggler span:first-child{
opacity: 0;
}
.container-chatbox.show-chatbot #chatbot-toggler span:last-child{
opacity: 1;
}
.chatbot-popup{
position: fixed;
opacity: 0;
pointer-events: none;
bottom:90px;
right: 35px;
width: 420px;
transform: scale(0.2);
overflow: hidden;
background: #fff;
border-radius: 15px;
transform-origin: bottom right;
box-shadow: 0 0 120px 0 rgba(0, 0, 0, 0.1),
0 32px 64px -48px rgba(0, 0, 0, 0.5);
transition: all 0.1s ease;
}
.container-chatbox.show-chatbot .chatbot-popup{
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.chatbot-popup .chat-header{
display: flex;
padding: 15px 22px;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #004a99, #0077cc);
}
.chat-header .header-info{
display: flex;
gap: 10px;
align-items: center;
}
.header-info svg{
height: 35px;
width: 35px;
padding: 6px;
flex-shrink: 0;
fill: #fff;
background: linear-gradient(135deg, #004a99, #0077cc);
border-radius: 50%;
}
.header-info .logo-text{
color: #fff;
font-size: 1.31rem;
font-weight: 600;
}
.chat-header button {
height: 40px;
width: 40px;
border: none;
outline: none;
color: #fff;
cursor: pointer;
font-size: 1.9rem;
padding-top: 2px;
border-radius: 50%;
margin-right: -10px;
background: none;
transition: 0.2s ease;
}
.chat-header button:hover{
background: linear-gradient(135deg, #004a99, #0077cc);
}
.chat-body{
display: flex;
flex-direction: column;
height: 460px;
margin-bottom: 82px;
overflow-y: auto;
padding: 25px 22px;
scrollbar-width: thin;
scrollbar-color: #DDD3F9 transparent;
}
.chat-body .message{
display: flex;
gap: 11px;
align-items: center;
}
.chat-body .bot-message.error .message-text{
color: #ff0000
}
.chat-body .bot-message svg{
height: 35px;
width: 35px;
padding: 6px;
flex-shrink: 0;
fill: #fff;
align-self: flex-end;
background: linear-gradient(135deg, #004a99, #0077cc);
border-radius: 50%;
}
.chat-body .message .message-text{
padding: 12px 16px;
max-width: 75%;
word-wrap: break-word;
white-space: pre-line;
font-size: 0.95rem;
}
.chat-body .bot-message .message-text{
background: #F6F2FF;
border-radius: 13px 13px 13px 3px;
}
.chat-body .user-message{
flex-direction: column;
align-items: flex-end;
}
.chat-body .user-message .message-text{
color: #fff;
background: #03649d;
border-radius: 13px 13px 3px 13px;
}
.chat-footer{
position: absolute;
bottom: 0;
width: 100%;
background: #fff;
padding: 15px 22px 20px;
}
.chat-footer .chat-form{
display: flex;
align-items: center;
background: #fff;
border-radius: 32px;
outline: 1px solid #CCCCE5;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.06);
}
.chat-footer .chat-form:focus-within{
outline: 2px solid #009EFB;
}
.chat-form .message-input{
border: none;
outline: none;
width: 100%;
background: none;
height: 47px;
padding: 0 17px;
font-size: 0.95rem;
}
.chat-form button{
height: 35px;
width: 32px;
border: none;
display: none;
outline: none;
cursor: pointer;
font-size: 1.15rem;
color: #fff;
flex-shrink: 0;
margin-right: 6px;
border-radius: 50%;
background: #009EFB;
transition: 0.2s ease;
}
.chat-form button:hover{
background: #03649d;
}
.chat-form .message-input:valid ~ button{
display: block;
}
@media (max-width: 520px){
#chatbot-toggler{
right: 20px;
bottom: 20px
}
.chatbot-popup{
right:0;
bottom: 0;
height: 100%;
border-radius: 0;
width: 100%;
}
.chatbot-popup .chat-header{
padding: 12px 15px;
}
.chat-body{
height: calc(90% -55px);
padding: 25px 15px;
}
.chat-footer{
padding: 10px 15px 15px;
}
}/* Corrige sobreposição do calendário sobre o Chatbot */
#chatbot-toggler,
.container-chatbox,
.chatbot-popup {
position: fixed !important;
z-index: 9999 !important; /* Fica acima de qualquer componente, incluindo o FullCalendar */
}
/* Garante que o popup também não fique atrás de nada */
.chatbot-popup {
z-index: 9998 !important;
}

1841
src/assets/css/darkmode.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

483
src/assets/css/hospital.css Normal file
View File

@ -0,0 +1,483 @@
/* ======== CONFIGURAÇÕES GERAIS ======== */
body{
margin: 0;
padding: 0 !important;
font-family: 'Poppins', sans-serif;
scroll-behavior: smooth;
background-color: #f9fbfc;
color: #333;
}
.hospital-container {
width: 90%;
max-width: 1200px;
margin: 0 auto;
}
h1, h2, h3, h4 {
color: #003366;
margin-bottom: 0.5rem;
}
p {
line-height: 1.6;
color: #555;
}
/* ======== HEADER ======== */
.hospital-landing-header {
position: fixed;
top: 0;
width: 100%;
background: linear-gradient(135deg, #004a99, #0077cc);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
z-index: 1000;
transition: all 0.3s ease-in-out;
}
.hospital-header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
}
.hospital-logo-area {
display: flex;
align-items: center;
gap: 0.6rem;
margin-left: -200px;
}
.hospital-logo-area img {
width: 44px;
height: 44px;
filter: brightness(0) invert(1);
}
.hospital-logo-area h1 {
font-size: 1.6rem;
font-weight: 600;
color: #ffffff;
}
.hospital-nav-links {
display: flex;
align-items: center;
gap: 2rem;
}
.hospital-nav-links a {
text-decoration: none;
color: #ffffff;
font-weight: 500;
position: relative;
transition: 0.3s ease;
}
.hospital-nav-links a::after {
content: "";
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 2px;
background: #ffffff;
transition: width 0.3s ease;
}
.hospital-nav-links a:hover::after {
width: 100%;
}
.hospital-btn-login {
display: inline-block;
padding: 0.6rem 1.4rem;
background-color: #ffffff !important;
color: #0077cc !important;
border-radius: 30px;
font-weight: 600;
text-decoration: none;
border: 2px solid #0077cc;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.hospital-btn-login:hover {
transform: translateY(-2px);
}
.hospital-landing-header.scrolled {
background: linear-gradient(135deg, #003d80, #0065b3);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
/* ======== HERO ======== */
.hospital-hero-bg-section {
position: relative;
height: 75vh;
background-image: url("/img/pexels-mart-production-7088498.jpg");
background-repeat: no-repeat;
background-position: center top;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.hospital-hero-bg-content {
position: relative;
text-align: center;
color: white;
background: rgba(0, 0, 0, 0.4);
padding: 2rem 3rem;
border-radius: 12px;
max-width: 700px;
}
.hospital-hero-bg-content h1 {
font-size: 2.8rem;
font-weight: 700;
margin-bottom: 1rem;
}
.hospital-hero-bg-content p {
font-size: 1.2rem;
line-height: 1.6;
}
/* ======== SOBRE ======== */
.hospital-about-section {
padding: 5rem 0;
background: #fff;
}
.hospital-about-content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2rem;
}
.hospital-about-image {
flex: 1;
}
.hospital-about-image img {
width: 100%;
border-radius: 16px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
.hospital-about-text {
flex: 1;
min-width: 300px;
}
.hospital-about-highlights {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
}
.hospital-about-highlights div {
background: #e6f2ff;
color: #005fa3;
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 500;
}
/* ======== ESPECIALIDADES ======== */
.hospital-specialities-section {
background: #f5faff;
padding: 5rem 0;
text-align: center;
}
.hospital-section-header h3 {
font-size: 2rem;
}
.hospital-specialities-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-top: 3rem;
}
.hospital-speciality-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
width: 180px;
text-align: center;
transition: 0.3s;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
}
.hospital-speciality-card img {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.hospital-speciality-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
}
/* ======== MÉDICO GIGANTE ======== */
.hospital-doctor-highlight-section {
background: #fff;
padding: 6rem 0 8rem;
overflow: hidden;
}
.hospital-doctor-highlight {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4rem;
}
.hospital-doctor-image {
flex: 1;
text-align: center;
}
.hospital-doctor-image img {
width: 100%;
max-width: 520px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.1));
}
.hospital-doctor-text {
flex: 1;
min-width: 300px;
}
.hospital-doctor-text h3 {
font-size: 2rem;
color: #003366;
margin-bottom: 1rem;
}
.hospital-doctor-text p {
color: #555;
margin-bottom: 2rem;
max-width: 500px;
}
/* ======== CONTATO ======== */
.hospital-contact-section {
background: linear-gradient(135deg, #e9f5ff, #f5faff);
padding: 6rem 0;
}
.hospital-contact-wrapper {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 3rem;
}
.hospital-contact-info {
flex: 1;
min-width: 300px;
}
.hospital-contact-cards {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.hospital-contact-item {
display: flex;
align-items: center;
background: white;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
gap: 1rem;
}
.hospital-contact-form {
flex: 1;
min-width: 300px;
background: white;
padding: 2rem;
border-radius: 16px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
}
.hospital-contact-form input,
.hospital-contact-form textarea {
width: 100%;
padding: 0.8rem;
margin-bottom: 1rem;
border: 1px solid #d0d7de;
border-radius: 8px;
font-size: 1rem;
}
.hospital-contact-form button {
width: 100%;
font-weight: 600;
}
/* ======== FOOTER ======== */
.hospital-landing-footer {
background: linear-gradient(135deg, #002b5c, #004c99);
color: white;
text-align: center;
padding: 3rem 0 2rem;
}
.hospital-footer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.hospital-footer-socials {
display: flex;
gap: 1.2rem;
}
.hospital-footer-socials a {
color: white;
font-size: 1.4rem;
transition: 0.3s;
}
.hospital-footer-logo img {
width: 36px;
height: 36px;
filter: brightness(0) invert(1);
}
/* ==========================================================
RESPONSIVIDADE
========================================================== */
/* --- Navbar responsiva --- */
@media (max-width: 768px) {
.hospital-header-content {
flex-direction: column;
gap: 1rem;
padding: 1rem 0.5rem;
}
.hospital-logo-area {
margin-left: 0;
justify-content: center;
}
.hospital-nav-links {
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.hospital-btn-login {
padding: 0.5rem 1rem;
}
}
/* --- Hero responsivo --- */
@media (max-width: 768px) {
.hospital-hero-bg-section {
height: 55vh;
background-position: center;
}
.hospital-hero-bg-content h1 {
font-size: 2rem;
}
.hospital-hero-bg-content p {
font-size: 1rem;
}
}
/* --- Médico gigante responsivo --- */
@media (max-width: 768px) {
.hospital-doctor-highlight {
flex-direction: column;
text-align: center;
}
.hospital-doctor-image img {
max-width: 300px;
}
}
/* --- Responsividade geral --- */
@media (max-width: 768px) {
.hospital-about-content,
.hospital-contact-wrapper {
flex-direction: column;
}
}
a.hospital-btn-login,
a.hospital-btn-login:link,
a.hospital-btn-login:visited,
button.hospital-btn-login {
display: inline-block !important;
padding: 0.6rem 1.4rem !important;
background-color: #ffffff !important;
color: #0077cc !important;
border-radius: 30px !important;
font-weight: 600 !important;
text-decoration: none !important;
border: 2px solid #0077cc !important;
transition: all 0.3s ease !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important;
}
a.hospital-btn-login:hover,
button.hospital-btn-login:hover {
transform: translateY(-2px) !important;
}
a.hospital-btn-primary,
button.hospital-btn-primary,
input[type="submit"].hospital-btn-primary {
display: inline-block !important;
background: #0077cc !important;
color: #ffffff !important;
padding: 0.8rem 1.5rem !important;
border-radius: 6px !important;
font-weight: 500 !important;
text-decoration: none !important;
border: none !important;
}
a.hospital-btn-primary:hover,
button.hospital-btn-primary:hover {
background: #005fa3 !important;
}
.btn.hospital-btn-login,
.btn.hospital-btn-primary,
button.btn.hospital-btn-login {
background: unset !important;
color: unset !important;
}

View File

@ -22,6 +22,11 @@
@import './tiptap.css';
@import './chatbot.css';
@import './Dashboard.css';
@import './DoctorDashboard.css';
/* Estilos para cards de eventos */
.event-card {
padding: 6px 10px;
@ -150,3 +155,286 @@
font-size: 0.7rem;
min-width: 80px;
}
/* Linha separadora do dropdown */
.dropdown-divider {
border: none;
border-top: 1px solid #e5e5e5;
margin: 8px 12px;
height: 0;
}
/* Estilo específico para botão de upload */
.dropdown-item i {
margin-right: 8px;
width: 14px;
text-align: center;
}
/* Botão de logout destacado */
.logout-btn {
color: #e63946 !important;
border: none;
border-radius: 6px;
padding: 8px 12px;
font-weight: 600;
width: 100%;
text-align: left;
margin-top: 6px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.logout-btn:hover {
background-color: #fef2f2 !important;
color: #dc2626 !important;
}
/* Dropdown com leve sombra e padding */
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
left: auto;
z-index: 1000;
min-width: 160px;
padding: 5px;
margin: 5px 0 0 0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
display: none;
}
.dropdown-menu.show {
display: block;
}
/* Estilo dos outros itens do dropdown */
.dropdown-item {
background: none;
border: none;
width: 100%;
text-align: left;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
color: #333;
cursor: pointer;
transition: background 0.2s ease;
}
.dropdown-item:hover {
background-color: #f1f1f1;
}
.dropdown-header {
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
color: #666;
border-bottom: 1px solid #eee;
margin-bottom: 5px;
}
/* Estilo para o container do dropdown */
.dropdown {
position: relative;
}
/* Estilo específico para o dropdown do perfil */
.nav-item.dropdown {
position: relative;
}
/* Estilo específico para o user-menu dropdown */
.user-menu .nav-item.dropdown .dropdown-menu {
position: absolute !important;
top: 100% !important;
right: 0 !important;
left: auto !important;
transform: translateX(0) !important;
min-width: 180px;
margin-top: 8px;
}
.dm-container {
display: flex;
align-items: center;
margin-right: 12px;
}
.dm-button {
background: none;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, filter 0.2s ease;
font-size: 20px;
}
.dm-button:focus {
outline: none;
box-shadow: none;
}
.dm-button:hover {
transform: scale(1.2);
filter: brightness(1.2);
}
/* Cores */
.dm-button.light {
color: #fff;
}
.dm-button.dark {
color: #f1f1f1;
}
.action-buttons-container {
display: flex;
gap: 5px;
justify-content: flex-end;
align-items: center;
}
.action-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 8px 10px;
margin: 0 2px;
border-radius: 6px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
display: inline-block;
}
.action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.action-btn:hover::before {
left: 100%;
}
.action-btn-view {
color: #007bff;
}
.action-btn-view:hover {
background-color: #007bff;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,123,255,0.3);
}
.action-btn-edit {
color: #28a745;
}
.action-btn-edit:hover {
background-color: #28a745;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(40,167,69,0.3);
}
.action-btn-delete {
color: #dc3545;
}
.action-btn-delete:hover {
background-color: #dc3545;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(220,53,69,0.3);
}
.action-btn-print {
color: #17a2b8;
}
.action-btn-print:hover {
background-color: #17a2b8;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(23,162,184,0.3);
}
/* Efeito de tooltip melhorado */
.action-btn {
position: relative;
}
.action-btn::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 1000;
}
.action-btn:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-5px);
}
/* Container para os botões de ação */
.action-buttons-container {
display: flex;
gap: 5px;
justify-content: flex-end;
align-items: center;
}
/* Responsividade para os botões */
@media (max-width: 768px) {
.action-btn {
padding: 6px 8px;
margin: 0 1px;
}
.action-buttons-container {
gap: 3px;
}
}
/* Efeito focus para acessibilidade */
.action-btn:focus {
outline: 2px solid transparent;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.action-btn-edit:focus {
box-shadow: 0 0 0 2px rgba(40,167,69,0.25);
}
.action-btn-delete:focus {
box-shadow: 0 0 0 2px rgba(220,53,69,0.25);
}

398
src/assets/css/login.css Normal file
View File

@ -0,0 +1,398 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Variáveis de Cores */
:root {
--primary-blue: #007bff; /* Azul principal do botão */
--light-blue: #e9f5ff; /* Fundo azul claro para a seção de imagens */
--dark-green: #38b000; /* Verde escuro para a imagem da paciente */
--text-dark: #333;
--text-light: #666;
--border-color: #ddd;
--background-grey: #f8f9fa; /* Cinza suave para o fundo */
}
/* Reset Básico */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.login-body {
font-family: 'Inter', sans-serif;
background-color: var(--background-grey);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
display: flex;
width: 100% !important;
max-width: 1200px; /* Largura máxima para a composição */
background-color: #fff;
border-radius: 12px;
overflow: hidden;
padding-left: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
/* ------------------- Seção de Imagens (Lado Esquerdo) ------------------- */
.image-section {
flex: 1;
padding: 50px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(#eff3ff, #dde1ea)
}
.image-box {
width: 250px;
height: 250px;
overflow: hidden;
position: relative;
}
.content-section {
display: flex; /* Habilita o Flexbox para alinhar imagem e texto lado a lado */
align-items: center; /* Alinha verticalmente os itens no centro */
gap: 20px;
}
.patient-info {
flex-direction: row-reverse;
}
/* Cores de Fundo (Simulando a imagem) */
.doctor-box {
margin-right: 40%;
background: transparent; /* Gradiente azul para o médico */
}
.patient-box {
margin-left: 40%;
background-color: transparent; /* Verde sólido para a paciente */
}
/* Para simular as imagens (substitua por tags <img> reais se tiver os arquivos) */
.doctor-image, .patient-image {
position: absolute;
width: 100%;
height: 100%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.image-box,
.text-box {
align-self: stretch !important;
flex-grow: 1 ;
display: flex ;
flex-direction: column ;
justify-content: center;
margin-left: 0 ;
margin-right: 0 ;
}
.text-box h3{
font-size: 24px !important;
color: #003366 !important;
}
.doctor-image {
/* Esta URL é uma placeholder. Substitua pela URL da sua imagem. */
background-image: url('/img/login/medico_container.png') !important;
}
.patient-image {
/* Esta URL é uma placeholder. Substitua pela URL da sua imagem. */
background-image: url('/img/login/paciente_container.png') !important;
}
/* ------------------- Seção de Login (Lado Direito) ------------------- */
.login-section {
width: 450px; /* Largura fixa para a seção de login */
padding: 60px 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.app-header {
display: flex;
align-items: center;
margin-bottom: 40px;
}
.app-icon {
color: var(--primary-blue);
font-size: 24px;
margin-right: 8px;
}
.app-name {
font-size: 20px;
font-weight: 600;
color: var(--text-dark);
}
/* Tabs */
.tab-container {
display: flex;
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 40px;
}
.tab-button {
flex: 1;
padding: 12px 0;
font-size: 14px;
font-weight: 500;
border: none;
background-color: #fff;
cursor: pointer;
color: var(--text-dark);
transition: background-color 0.2s, color 0.2s;
}
.tab-button:first-child {
border-right: 1px solid var(--border-color);
}
.tab-button.active {
background-color: var(--primary-blue);
color: #fff;
box-shadow: 0 2px 5px rgba(0, 123, 255, 0.3);
}
/* Formulário */
.login-form-container {
width: 100%;
}
.login-title {
font-size: 24px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 8px;
color: #003366 !important;
}
.login-subtitle {
font-size: 14px;
color: var(--text-light);
margin-bottom: 30px;
}
.input-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-dark);
margin-bottom: 8px;
}
.input-group {
display: flex;
align-items: center;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 15px;
margin-bottom: 20px;
background-color: #f9f9f9;
}
.input-group input {
flex-grow: 1;
border: none;
outline: none;
font-size: 16px;
background-color: transparent;
color: var(--text-dark);
padding: 0;
}
.input-group .input-icon, .toggle-password {
color: var(--text-light);
margin-right: 10px;
}
.password-input .input-icon {
margin-right: 15px;
}
.password-input .toggle-password {
cursor: pointer;
margin-left: 10px;
margin-right: 0;
}
/* Input de Telefone */
.phone-input {
padding-left: 0;
}
.phone-code {
background-color: #eee;
padding: 0 15px;
margin-right: 15px;
height: 100%;
display: flex;
align-items: center;
font-size: 16px;
color: var(--text-dark);
border-right: 1px solid var(--border-color);
/* Ajuste para alinhar com a altura do input */
line-height: 1.5;
}
.phone-input input {
font-family: 'Inter', sans-serif;
padding: 0;
margin-left: 5px;
}
/* Link de Senha */
.reset-password {
display: block;
text-align: right;
font-size: 14px;
color: var(--primary-blue);
text-decoration: none;
margin-bottom: 30px;
font-weight: 500;
}
/* Botão Principal de Login */
.login-button {
font-family: 'Inter', sans-serif;
width: 100%;
padding: 14px;
background-color: var(--primary-blue);
color: #fff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
margin-bottom: 20px;
}
.login-button:hover {
background-color: #0056b3;
}
/* Link Login com Código */
.login-with-code {
display: block;
text-align: center;
font-size: 14px;
color: var(--primary-blue);
text-decoration: none;
font-weight: 500;
}
/* Responsividade Básica */
@media (max-width: 992px) {
.container {
flex-direction: column;
}
.image-section {
display: none; /* Oculta a seção de imagens em telas menores */
}
.login-section {
width: 100%;
max-width: 450px;
padding: 40px 20px;
}
}
/* Estilo para a borda do campo de erro */
.input-error {
border: 2px solid #e74c3c !important; /* Vermelho forte */
background-color: #fcebeb; /* Fundo levemente rosado */
outline: none;
}
/* Estilo para a mensagem de erro (oculta por padrão) */
.error-message {
color: #e74c3c; /* Cor do texto de erro */
font-size: 0.9em;
margin-top: 5px;
display: none; /* Inicia oculta */
}
/* Mostra a mensagem de erro quando necessário */
.error-visible {
display: block;
}
/* ======================================= */
/* 📱 RESPONSIVIDADE MOBILE */
/* ======================================= */
/* Aplica regras quando a tela for menor que 768px (tablets e celulares) */
@media (max-width: 768px) {
.container {
padding: 10px;
}
/* 1. Empilha a Imagem e o Texto verticalmente */
.content-section {
flex-direction: column;
gap: 15px; /* Reduz o espaço entre a imagem e o texto no mobile */
text-align: center; /* Centraliza o texto */
}
/* 2. Remove a inversão da ordem na seção do paciente */
.patient-info {
flex-direction: column; /* Sobrescreve row-reverse */
}
/* 3. Ajusta o tamanho da Imagem para Mobile (menor) */
.image-box {
width: 280px; /* Reduz o tamanho da imagem para caber melhor na tela */
height: 280px;
}
/* 4. Ajusta os tamanhos de fonte no Mobile */
.text-box h3 {
font-size: 1.5em; /* Título menor */
color: #003366 !important;
}
.text-box p {
font-size: 1em; /* Parágrafo menor */
}
/* Garante que o texto não tenha largura máxima que o force a centralizar mal */
.text-box {
max-width: 90%; /* Limita a largura do texto em 90% da tela */
}
}
.recaptcha-wrapper {
margin: 20px 0;
display: flex;
justify-content: center;
width: 100%;
}
@media (max-width: 768px) {
.recaptcha-wrapper {
margin: 15px 0;
}
}

View File

@ -0,0 +1,91 @@
/* Fundo do modal */
.modal.fade.show {
display: block;
background-color: rgba(0, 0, 0, 0.6); /* escurece o fundo */
backdrop-filter: blur(2px); /* leve desfoque atrás */
transition: background-color 0.3s;
}
/* Caixa do modal */
.modal-dialog {
max-width: 700px;
margin: 1.75rem auto;
}
.modal-content {
border-radius: 12px;
border: none;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
overflow: hidden;
animation: slideDown 0.3s ease-out;
}
/* Animação do modal */
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Cabeçalho */
.modal-header {
background-color: #007bff;
color: #fff;
border-bottom: none;
padding: 15px 20px;
}
.modal-header .close {
color: #fff;
opacity: 1;
font-size: 24px;
}
/* Título */
.modal-title {
font-weight: 600;
font-size: 18px;
}
/* Corpo do modal */
.modal-body {
padding: 20px;
font-size: 14px;
color: #333;
}
/* Labels e valores */
.modal-body p strong {
color: #007bff;
}
/* Colunas */
.modal-body .col-md-6 {
margin-bottom: 15px;
}
/* Rodapé */
.modal-footer {
border-top: none;
padding: 15px 20px;
justify-content: flex-end;
}
/* Botão fechar */
.modal-footer .btn-secondary {
background-color: #007bff;
border: none;
border-radius: 5px;
padding: 6px 14px;
transition: all 0.2s;
}
.modal-footer .btn-secondary:hover {
background-color: #5a6268;
}

View File

@ -56,18 +56,23 @@ Version : 1.0
1. General
-----------------------*/
@import url('https://fonts.googleapis.com/css?family=Rubik:400,500,700');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
html {
height: 100%;
height: auto;
min-height: 100%;
}
body {
font-family: 'Rubik', sans-serif;
font-size: 0.875rem;
color: #666;
background-color: #fafafa;
overflow-x: hidden;
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.875rem;
color: #666;
background-color: #fafafa;
overflow-x: hidden;
overflow-y: auto; /* permite rolar */
min-height: 100vh; /* ocupa toda a tela e cresce conforme o conteúdo */
height: auto;
}
h1,
h2,
h3,
@ -173,15 +178,36 @@ textarea.form-control {
2. Table
-----------------------*/
.table {
color: #000;
border: 1px solid #f0f0f0;
.table.custom-table > tbody > tr > td {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: 150px !important;
}
table {
color: #000;
border-radius: 10px ;
overflow: hidden;
}
.table.table-white {
background-color: #fff;
border-color: #0077cc;
}
.table>thead>tr {
background: linear-gradient(135deg, #004a99, #0077cc);
}
.table>thead>tr>th{
color: white;
}
.table > tbody > tr > td {
font-weight: 300;
background-color: #fff;
}
.table-striped > tbody > tr:nth-of-type(2n + 1) {
background-color: #f6f6f6;
@ -433,7 +459,8 @@ table.table td h2 span {
}
.btn-primary {
border-color: transparent;
background-color: #009efb;
background: linear-gradient(135deg, #004a99, #0077cc);
}
.btn-primary:hover,
.btn-primary:focus,
@ -571,12 +598,11 @@ table.table td h2 span {
}
.pagination > li > a,
.pagination > li > span {
color: #009efb;
background: linear-gradient(135deg, #004a99, #0077cc);
padding: .5rem .75rem !important;
}
.page-item.active .page-link {
background-color: #009efb;
border-color: #009efb;
background: linear-gradient(135deg, #004a99, #0077cc);
}
.dropdown-menu {
border: 1px solid rgba(0, 0, 0, 0.1);
@ -665,6 +691,7 @@ textarea.form-control {
}
.form-control.form-control-sm {
padding: 0.25rem 0.5rem;
border-radius: 10px
}
.card .card-header {
background-color: rgba(255, 255, 255, 0.1);
@ -702,7 +729,7 @@ textarea.form-control {
-----------------------*/
.header {
background-color: #009efb;
background: linear-gradient(135deg, #004a99, #0077cc);
left: 0;
position: fixed;
right: 0;
@ -730,7 +757,7 @@ textarea.form-control {
color: #fff;
font-size: 18px;
font-weight: 500;
margin-left: 10px;
margin-left: 0px;
}
.header .navbar-nav .badge {
position: absolute;
@ -824,7 +851,7 @@ textarea.form-control {
top: 50px;
width: 230px;
z-index: 1039;
background-color: #fff;
background-color: #FFFFFF;
bottom: 0;
margin-top: 0px;
position: fixed;
@ -844,38 +871,89 @@ textarea.form-control {
font-size: 14px;
list-style-type: none;
margin: 0;
padding: 0;
padding: 8px 0;
}
.sidebar-menu li a {
color: #888;
display: block;
font-size: 15px;
color: #777;
display: flex;
align-items: center;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
height: auto;
padding: 0 20px;
padding: 12px 16px;
margin: 2px 8px;
border-radius: 10px;
transition: all 0.3s ease;
text-decoration: none;
}
.sidebar-menu li a i {
color: #777;
margin-right: 12px;
width: 16px;
text-align: center;
transition: color 0.3s ease;
}
.sidebar-menu li a:hover {
color: #009efb;
color: #007BFF;
background-color: #E8F2FF;
transform: translateX(4px);
}
.sidebar-menu li.active a {
color: #009efb;
background-color: #f3f3f3;
.sidebar-menu li a:hover i {
color: #007BFF;
}
.sidebar-menu li.active a,
.sidebar-menu li a.active {
color: #007BFF;
background-color: #E8F2FF;
font-weight: 500;
}
.sidebar-menu li.active a i,
.sidebar-menu li a.active i {
color: #007BFF;
}
/* Separadores de seção */
.sidebar-menu li.separator {
border-top: 1px solid #EAEAEA;
margin: 16px 0;
padding-top: 16px;
}
/* Melhor espaçamento para o ul */
.sidebar-menu ul {
font-size: 14px;
list-style-type: none;
margin: 0;
padding: 8px 0;
}
/* Estilo para span dentro dos links */
.sidebar-menu li a span {
font-family: 'Inter', sans-serif;
font-weight: inherit;
}
.menu-title {
color: #333;
font-size: 15px;
font-weight: 500;
padding: 12px 20px;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 16px 16px 8px 16px;
}
.menu-title > i {
float: right;
line-height: 40px;
}
.sidebar-menu li.menu-title a {
color: #009efb;
display: inline-block;
float: right;
padding: 0;
color: #009efb;
float: right;
padding: 0;
text-decoration: none; /* opcional */
}
.sidebar-menu li.menu-title a.btn {
color: #fff;
display: block;
@ -933,6 +1011,36 @@ textarea.form-control {
.mobile_btn {
display: none;
}
@media (max-width: 991.98px) {
/* Faz o botão de menu (hambúrguer) aparecer */
.mobile_btn {
display: block; /* Ou 'inline-block', dependendo do seu layout */
float: left;
color: white; /* Ajuste a cor para combinar com seu header */
font-size: 24px;
line-height: 50px; /* Centraliza verticalmente (use a altura do header) */
padding: 0 15px;
cursor: pointer; /* Muda o cursor para indicar que é clicável */
}
/* 1. Por padrão, em mobile, a sidebar fica ESCONDIDA para a esquerda */
.sidebar {
transform: translateX(-100%); /* Move a sidebar totalmente para fora da tela */
z-index: 1040; /* Garante que a sidebar fique na frente do conteúdo */
}
/* 2. REGRA PRINCIPAL: Quando a classe 'sidebar-open' é adicionada ao
'main-wrapper', a sidebar desliza para dentro da tela */
.main-wrapper.sidebar-open .sidebar {
transform: translateX(0);
}
/* 3. Em mobile, o conteúdo principal não deve ser "empurrado" pela sidebar */
.page-wrapper {
margin-left: 0 !important;
}
}
.sidebar .sidebar-menu > ul > li > a span {
transition: all 0.2s ease-in-out 0s;
display: inline-block;
@ -1266,11 +1374,11 @@ ul.chat-user-total li i.old-users {
border-top: 1px solid #000;
}
.bar-chart > .legend > .item {
position: relative;
display: inline-block;
float: left;
width: 25%;
position: relative;
float: left;
width: 25%;
}
.bar-chart > .legend > .item:before {
display: block;
position: absolute;
@ -2217,12 +2325,12 @@ ul.chat-user-total li i.old-users {
margin-right: 12px;
}
.chat-avatar-sm {
width: 24px;
margin-right: 10px;
display: inline-block;
position: relative;
float: left;
width: 24px;
margin-right: 10px;
position: relative;
float: left;
}
.chat-avatar-sm img {
width: 24px;
}
@ -2579,12 +2687,12 @@ a.fc-event:not([href]) {
.fc-toolbar h2 {
font-size: 18px;
font-weight: 600;
font-family: 'Rubik', sans-serif;
font-family: 'Inter', sans-serif;
line-height: 30px;
text-transform: uppercase;
}
.fc-day-grid-event .fc-time {
font-family: 'Rubik', sans-serif;
font-family: 'Inter', sans-serif;
}
.fc-day {
background: #fff;
@ -2658,12 +2766,12 @@ a.fc-event:not([href]) {
.fc-basic-view td.fc-week-number span {
padding-right: 8px;
font-weight: 700;
font-family: 'Rubik', sans-serif;
font-family: 'Inter', sans-serif;
}
.fc-basic-view td.fc-day-number {
padding-right: 8px;
font-weight: 700;
font-family: 'Rubik', sans-serif;
font-family: 'Inter', sans-serif;
}
/*-----------------
@ -3906,11 +4014,11 @@ blockquote p {
padding: 0;
}
.social-share > li {
display: inline-block;
float: left;
margin-left: 10px;
text-align: center;
float: left;
margin-left: 10px;
text-align: center;
}
.social-share > li:first-child {
margin-left: 0;
}
@ -4385,6 +4493,7 @@ blockquote p {
box-shadow: 0 6px 15px rgba(36, 37, 38, 0.08);
}
/*-----------------
42. Responsive
-----------------------*/
@ -4567,7 +4676,6 @@ blockquote p {
display: none;
}
.sidebar {
margin-left: -225px;
width: 225px;
}
.page-wrapper {
@ -4767,10 +4875,9 @@ blockquote p {
height: 120px;
}
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-ms-overflow-style: -ms-autohiding-scrollbar;
overflow-x: auto; /* A mágica acontece aqui! */
-webkit-overflow-scrolling: touch;
}
.header .has-arrow .dropdown-toggle > span:nth-child(2) {
display: none;
@ -4912,4 +5019,113 @@ blockquote p {
margin: 0 auto;
margin-bottom: 10px;
}
}
/* ================================================= */
/* ===== GERAL: Telas Médias (max-width: 991px) ===== */
/* ================================================= */
@media (max-width: 991px) {
.header .header-left {
width: 180px;
padding: 0 10px;
}
.logo span {
font-size: 16px;
}
.page-title-box h3 {
font-size: 16px;
}
.user-menu.nav > li > a {
padding: 0 10px;
font-size: 15px;
}
}
/* ========================================================= */
/* ===== CORREÇÃO DEFINITIVA MOBILE (max-width: 768px) ==== */
/* ========================================================= */
@media (max-width: 768px) {
/* Impede rolagem lateral */
html, body {
overflow-x: hidden;
}
/* 1. Header como Container Flex */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50px;
z-index: 1050;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}
/* 2. Botão da Sidebar (#mobile_btn) - Fica à esquerda */
#mobile_btn {
position: static;
z-index: 1045;
order: 1;
margin-right: 10px;
}
/* 3. Container da Logo (.header-left) - REMOVIDO NO MOBILE */
.header .header-left {
/* A logo será escondida */
display: none;
}
/* Ajuste para que o Menu e o Botão fiquem nas extremidades quando a Logo é removida */
.user-menu {
position: static;
width: auto;
order: 3;
height: 50px;
display: flex;
align-items: center;
float: none;
padding-right: 0;
/* Centraliza o Menu do Usuário corretamente à direita quando a logo desaparece */
margin-left: auto;
}
/* Esconde o título */
.page-title-box {
display: none;
}
/* Ajustes finos de tamanho */
.user-menu.nav > li > a {
padding: 0 8px;
font-size: 14px;
}
}
/* ========================================================= */
/* ===== GERAL: Telas Muito Pequenas (max-width: 480px) ===== */
/* ========================================================= */
@media (max-width: 480px) {
/* Não necessidade de regras para .logo span ou .header-left aqui,
pois o display: none foi aplicado acima. */
.user-menu.nav > li > a {
padding: 0 6px;
font-size: 13px;
}
.header .user-img img {
width: 20px;
}
}

View File

@ -25,6 +25,7 @@
img {
max-width: 50%;
border-radius: 5px;
display: block;
}
}

View File

@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import "../assets/css/darkmode.css";
const LS_KEYS = {
dark: "pref_dark_mode",
daltonism: "pref_daltonism",
font: "pref_font_scale",
};
export default function AccessibilityWidget() {
const [open, setOpen] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [daltonismMode, setDaltonismMode] = useState(false);
const [fontScale, setFontScale] = useState(100);
const [leituraAtiva, setLeituraAtiva] = useState(false);
// ---------- LEITURA AUTOMÁTICA ----------
const lerTextoSelecionado = () => {
const texto = window.getSelection().toString().trim();
if (!texto) return;
window.speechSynthesis.cancel();
const fala = new SpeechSynthesisUtterance(texto);
fala.lang = "pt-BR";
fala.rate = 1;
fala.pitch = 1;
window.speechSynthesis.speak(fala);
};
useEffect(() => {
const handleSelectionChange = () => {
if (!leituraAtiva) return;
const texto = window.getSelection().toString().trim();
if (texto.length > 1) lerTextoSelecionado();
};
if (leituraAtiva) {
document.addEventListener("selectionchange", handleSelectionChange);
} else {
document.removeEventListener("selectionchange", handleSelectionChange);
window.speechSynthesis.cancel();
}
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, [leituraAtiva]);
// ---------- CARREGAR PREFERÊNCIAS ----------
useEffect(() => {
const savedDark = localStorage.getItem(LS_KEYS.dark) === "true";
const savedDaltonism = localStorage.getItem(LS_KEYS.daltonism) === "true";
const savedFont = parseInt(localStorage.getItem(LS_KEYS.font) || "100", 10);
setDarkMode(savedDark);
setDaltonismMode(savedDaltonism);
setFontScale(savedFont);
document.body.classList.toggle("dark-mode", savedDark);
document.body.classList.toggle("daltonism-mode", savedDaltonism);
document.documentElement.style.fontSize = `${savedFont}%`;
}, []);
// ---------- FUNÇÕES DE MODO ----------
const toggleDarkMode = () => {
const next = !darkMode;
setDarkMode(next);
localStorage.setItem(LS_KEYS.dark, String(next));
document.body.classList.toggle("dark-mode", next);
};
const toggleDaltonismMode = () => {
const next = !daltonismMode;
setDaltonismMode(next);
localStorage.setItem(LS_KEYS.daltonism, String(next));
document.body.classList.toggle("daltonism-mode", next);
};
// ---------- CONTROLE DE FONTE ----------
const applyFontScale = (next) => {
const clamped = Math.max(80, Math.min(180, next));
setFontScale(clamped);
localStorage.setItem(LS_KEYS.font, String(clamped));
document.documentElement.style.fontSize = `${clamped}%`;
};
const incFont = () => applyFontScale(fontScale + 10);
const decFont = () => applyFontScale(fontScale - 10);
const resetFont = () => applyFontScale(100);
// ---------- JSX ----------
return (
<>
{/* Botão flutuante ♿ */}
<button
className={`acc-btn ${open ? "active" : ""}`}
aria-label="Abrir painel de acessibilidade"
onClick={() => setOpen(!open)}
>
{open ? "✕" : "♿"}
</button>
{/* Painel */}
{open && (
<div className="acc-panel">
<div className="acc-header">
<strong>Acessibilidade</strong>
<button className="acc-close" onClick={() => setOpen(false)}>
</button>
</div>
{/* Modo daltônico */}
<div className="acc-row">
<label className="acc-switch">
<input
type="checkbox"
checked={daltonismMode}
onChange={toggleDaltonismMode}
/>
<span className="acc-slider"></span>
<span className="acc-label">Modo daltônico</span>
</label>
</div>
{/* Controle de fonte */}
<div className="acc-row">
<span className="acc-label">Tamanho da fonte</span>
<div className="acc-font-controls">
<button onClick={decFont} title="Diminuir fonte">A</button>
<button onClick={resetFont} title="Resetar tamanho">A</button>
<button onClick={incFont} title="Aumentar fonte">A+</button>
</div>
</div>
{/* Leitura automática */}
<div className="acc-row">
<button
className={`acc-btn-read ${leituraAtiva ? "active" : ""}`}
onClick={() => setLeituraAtiva(!leituraAtiva)}
>
{leituraAtiva
? "🟢 Leitura automática ativada"
: "🔊 Ativar leitura automática"}
</button>
</div>
<div className="acc-footer">
<small>Atalho: Ctrl + Alt + A</small>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,456 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image';
import { useState, useEffect, useRef} from 'react';
import { Card, Collapse } from "react-bootstrap"; // <-- IMPORT CORRETO
import { ChevronDown, ChevronUp } from "lucide-react";
import { getAccessToken } from '../utils/auth';
import Select from 'react-select';
import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom';
import { FaMicrophone } from "react-icons/fa";
import { InterimMark } from '../utils/InterimMark'; // <-- Verifique se esse caminho está certo!
import { getUserRole } from '../utils/userInfo';
import { useLocation } from 'react-router-dom';
import { getDoctorId } from '../utils/userInfo';
function Bar({ comandos, handleSubmit, toggleRecording, isRecording }) {
const inputRef = useRef(null);
const handleAbrirExplorador = () => {
inputRef.current.click(); // abre o explorador
};
const handleArquivoSelecionado = (event) => {
const arquivo = event.target.files[0];
if (arquivo) {
const imageUrl = URL.createObjectURL(arquivo);
comandos.agregarImagen(imageUrl);
event.target.value = null;
}
};
return (
<>
<div className="toolbar">
<div className="left">
<button onClick={comandos.toggleBold} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 11H12.5C13.8807 11 15 9.88071 15 8.5C15 7.11929 13.8807 6 12.5 6H8V11ZM18 15.5C18 17.9853 15.9853 20 13.5 20H6V4H12.5C14.9853 4 17 6.01472 17 8.5C17 9.70431 16.5269 10.7981 15.7564 11.6058C17.0979 12.3847 18 13.837 18 15.5ZM8 13V18H13.5C14.8807 18 16 16.8807 16 15.5C16 14.1193 14.8807 13 13.5 13H8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleItalic} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 20H7V18H9.92661L12.0425 6H9V4H17V6H14.0734L11.9575 18H15V20Z"></path>
</svg>
</button>
<button onClick={comandos.toggleUnderline} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z"></path>
</svg>
</button>
{/*<button onClick={comandos.toggleCodeBlock} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
</button> */}
<button onClick={comandos.toggleH1} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 20H11V13H4V20H2V4H4V11H11V4H13V20ZM21.0005 8V20H19.0005L19 10.204L17 10.74V8.67L19.5005 8H21.0005Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH2} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4V11H11V4H13V20H11V13H4V20H2V4H4ZM18.5 8C20.5711 8 22.25 9.67893 22.25 11.75C22.25 12.6074 21.9623 13.3976 21.4781 14.0292L21.3302 14.2102L18.0343 18H22V20H15L14.9993 18.444L19.8207 12.8981C20.0881 12.5908 20.25 12.1893 20.25 11.75C20.25 10.7835 19.4665 10 18.5 10C17.5818 10 16.8288 10.7071 16.7558 11.6065L16.75 11.75H14.75C14.75 9.67893 16.4289 8 18.5 8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH3} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 8L21.9984 10L19.4934 12.883C21.0823 13.3184 22.25 14.7728 22.25 16.5C22.25 18.5711 20.5711 20.25 18.5 20.25C16.674 20.25 15.1528 18.9449 14.8184 17.2166L16.7821 16.8352C16.9384 17.6413 17.6481 18.25 18.5 18.25C19.4665 18.25 20.25 17.4665 20.25 16.5C20.25 15.5335 19.4665 14.75 18.5 14.75C18.214 14.75 17.944 14.8186 17.7056 14.9403L16.3992 13.3932L19.3484 10H15V8H22ZM4 4V11H11V4H13V20H11V13H4V20H2V4H4Z"></path>
</svg>
</button>
<button onClick={comandos.toggleParrafo} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6V21H10V16C6.68629 16 4 13.3137 4 10C4 6.68629 6.68629 4 10 4H20V6H17V21H15V6H12ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14V6Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaOrdenada} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM5 3V6H6V7H3V6H4V4H3V3H5ZM3 14V11.5H5V11H3V10H6V12.5H4V13H6V14H3ZM5 19.5H3V18.5H5V18H3V17H6V21H3V20H5V19.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaPontos} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<>
<input
type="file"
accept="image/*"
ref={inputRef}
onChange={handleArquivoSelecionado}
style={{ display: "none" }} // esconde o input
/>
<button onClick={handleAbrirExplorador}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M2.9918 21C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918ZM20 15V5H4V19L14 9L20 15ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path>
</svg>
</button>
</>
<button
onClick={toggleRecording}
className={`toolbar-button ${isRecording ? "active" : ""}`}
title={isRecording ? "Parar ditado" : "Iniciar ditado por voz"}
>
<FaMicrophone size={18} />
</button>
{/*<button onClick={comandos.agregarLink} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
</button> */}
</div>
<div className="right">
<button onClick={handleSubmit} className="btnGuardar">
<span>Enviar laudo</span>
</button>
</div>
</div>
</>
);
};
function LaudoConsulta() {
const location = useLocation();
const patient_id = location.state?.pacienteId;
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const navigate = useNavigate();
const [paciente, setPaciente] = useState([]);
const tokenUsuario = getAccessToken()
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions)
.then(response => response.json())
.then(result => setPaciente(Array.isArray(result) ? result : []))
.catch(error => console.log('error', error));
}, [])
const options = paciente.map(p => ({
value: p.id,
label: p.full_name
}));
function gerarOrderNumber() {
const prefixo = "REL";
const agora = new Date();
const ano = agora.getFullYear();
const mes = String(agora.getMonth() + 1).padStart(2, "0"); // adiciona 0 à esquerda se necessário
// Gerar um código aleatório de 6 caracteres (letras maiúsculas + números)
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let codigo = "";
for (let i = 0; i < 6; i++) {
codigo += chars.charAt(Math.floor(Math.random() * chars.length));
}
return `${prefixo}-${ano}-${mes}-${codigo}`;
}
// Exemplo de uso:
const orderNumber = gerarOrderNumber();
const [laudos, setLaudos] = useState({
patient_id: patient_id || "",
order_number: "",
exam: "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: "",
status: "draft",
requested_by: getDoctorId(),
});
const handlePacienteChange = (selected) => {
setLaudos(prev => ({
...prev,
patient_id: selected ? selected.value : ""
}));
};
const handleChange = (e) => {
const { name, value } = e.target;
setLaudos((prev) => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e) => {
const role = getUserRole();
e.preventDefault();
if (!laudos.patient_id || !laudos.diagnosis || !laudos.exam || !laudos.conclusion) {
Swal.fire({
title: "Por favor, preencha todos os campos obrigatórios.",
icon: "warning",
draggable: true
});
return;
}
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify(laudos);
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: raw,
redirect: 'follow'
};
fetch(`${supabaseUrl}/rest/v1/reports`, requestOptions)
.then(response => response.text())
.then(async result => {
console.log(result);
// Atualiza o status da consulta para completed
if (laudos.patient_id) {
await fetch(`${supabaseUrl}/rest/v1/appointments?id=eq.${location.state?.consultaId}`, {
method: 'PATCH',
headers: myHeaders,
body: JSON.stringify({ status: 'completed' })
});
}
Swal.fire({
title: "Laudo adicionado!",
icon: "success",
draggable: true
});
navigate(`/${role}/laudolist`);
})
.catch(error => console.log('error', error));
};
const [open, setOpen] = useState(false);
const editor = useEditor({
extensions: [StarterKit, Image, InterimMark ],
content: "",
onUpdate: ({ editor }) => {
setLaudos(prev => ({
...prev,
content_html: editor.getHTML()
}));
}
})
const [isRecording, setIsRecording] = useState(false);
const recognitionRef = useRef(null);
const lastInsertedRef = useRef({ from: -1, to: -1, text: '' });
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Seu navegador não suporta reconhecimento de voz 😢");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "pt-BR";
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event) => {
if (!editor) return;
const result = event.results[0];
const transcript = result[0].transcript;
const last = lastInsertedRef.current;
// --- CORREÇÃO DE LÓGICA ---
// Vamos rodar a deleção como um comando SEPARADO primeiro.
if (last.from !== -1 &&
editor.state.doc.textBetween(last.from, last.to) === last.text)
{
// Roda a deleção e PARA.
editor.chain().focus()
.deleteRange({ from: last.from, to: last.to })
.run();
}
// Pega a posição ATUAL (depois da deleção)
const currentPos = editor.state.selection.from;
if (result.isFinal) {
// --- RESULTADO FINAL (PRETO) ---
// Roda a inserção final como um comando SEPARADO.
editor.chain().focus()
.insertContent(transcript + ' ')
.run();
// Reseta a Ref
lastInsertedRef.current = { from: -1, to: -1, text: '' };
} else {
// --- RESULTADO PROVISÓRIO (CINZA) ---
// Esta é a nova estratégia: "Ligar" a mark, inserir, "Desligar" a mark.
// Roda tudo como um comando SEPARADO.
editor.chain()
.focus()
.setMark('interimMark') // <-- "Pincel cinza" LIGADO
.insertContent(transcript) // <-- Insere o texto
.unsetMark('interimMark') // <-- "Pincel cinza" DESLIGADO
.run();
// Atualiza a Ref com a posição do texto cinza
lastInsertedRef.current = {
from: currentPos,
to: currentPos + transcript.length,
text: transcript
};
}
// Não precisamos mais do 'editorChain.run()' aqui embaixo
};
recognition.onerror = (err) => {
// ... (código do onerror sem mudanças)
};
recognition.onend = () => {
// ... (código do onend sem mudanças)
};
recognitionRef.current = recognition;
return () => {
recognition.stop();
};
}, [editor, isRecording]);
const toggleRecording = () => {
if (!recognitionRef.current) return;
if (isRecording) {
// Usuário clicou para PARAR
setIsRecording(false); // <-- Seta o estado
recognitionRef.current.stop(); // <-- Para a API
// O 'onend' será chamado e fará a limpeza/confirmação.
} else {
// Usuário clicou para COMEÇAR
editor?.chain().focus().run();
setIsRecording(true); // <-- Seta o estado
recognitionRef.current.start(); // <-- Inicia a API
}
};
const comandos = {
toggleBold: () => editor.chain().focus().toggleBold().run(),
toggleItalic: () => editor.chain().focus().toggleItalic().run(),
toggleUnderline: () => editor.chain().focus().toggleUnderline().run(),
toggleCodeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
toggleH1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
toggleH2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
toggleH3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
toggleParrafo: () => editor.chain().focus().setParagraph().run(),
toggleListaOrdenada: () => editor.chain().focus().toggleOrderedList().run(),
toggleListaPontos: () => editor.chain().focus().toggleBulletList().run(),
agregarImagen: (url) => {
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
},
agregarLink: () => {
const url = window.prompt('URL do link')
if (url) {
editor.chain().focus().setLink({ href: url }).run()
}
}
}
return (
<div className="page-wrapper">
<div className="content">
<h4 className="page-title">Laudo Médico</h4>
<div className="d-flex flex-column align-items-left mt-5">
<Card style={{ width: "100%", borderRadius: "10px" }}>
<Card.Header
onClick={() => setOpen(!open)}
aria-controls="paciente-content"
aria-expanded={open}
className="d-flex justify-content-between align-items-center"
style={{
cursor: "pointer",
borderRadius: "25px",
padding: "12px 20px",
}}
>
<span>Informações do paciente</span>
{open ? <ChevronUp /> : <ChevronDown />}
</Card.Header>
<Collapse in={open}>
<div id="paciente-content" className="p-3">
<Select
options={options}
placeholder="Pesquisar paciente..."
isClearable
isSearchable
isDisabled
onChange={handlePacienteChange}
value={options.find(option => option.value === laudos.patient_id) || null}>
</Select>
<input
type="text"
className="form-control mb-2"
placeholder="Diagnóstico"
name='diagnosis'
value={laudos.diagnosis}
onChange={handleChange}
/>
<input
type="text"
className="form-control mb-2"
name='exam'
value={laudos.exam}
onChange={handleChange}
placeholder="Exame"
/>
<input
type="text"
className="form-control mb-2"
name='conclusion'
value={laudos.conclusion}
onChange={handleChange}
placeholder="Conclusão"
/>
</div>
</Collapse>
</Card>
</div>
<Bar comandos={comandos} handleSubmit={handleSubmit} toggleRecording={toggleRecording} isRecording={isRecording} />
<EditorContent editor={editor} />
</div>
</div>
);
}
export default LaudoConsulta;

View File

@ -1,117 +0,0 @@
// src/components/Navbar.jsx
import "../assets/css/index.css";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
function Navbar() {
const location = useLocation();
const navigate = useNavigate();
const [openNotif, setOpenNotif] = useState(false);
const [openProfile, setOpenProfile] = useState(false);
const notifRef = useRef(null);
const profileRef = useRef(null);
const isDoctor = location.pathname.startsWith("/doctor");
const profileName = isDoctor ? "Médico" : "Admin";
// Fecha dropdowns ao clicar fora
useEffect(() => {
function handleClickOutside(e) {
if (notifRef.current && !notifRef.current.contains(e.target)) {
setOpenNotif(false);
}
if (profileRef.current && !profileRef.current.contains(e.target)) {
setOpenProfile(false);
}
}
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const goToOtherRole = () => {
if (isDoctor) navigate("/");
else navigate("/doctor");
setOpenProfile(false);
};
return (
<div className="header">
<div className="header-left">
{/* Logo dinâmica */}
<Link to={isDoctor ? "/doctor" : "/"} className="logo">
<img src="/img/logomedconnect.png" width="35" height="35" alt="" />{" "}
<span>MediConnect</span>
</Link>
</div>
<a id="mobile_btn" className="mobile_btn float-left" href="#sidebar">
<i className="fa fa-bars"></i>
</a>
<ul className="nav user-menu float-right">
{/* 🔔 Notificações */}
<li className="nav-item dropdown d-none d-sm-block" ref={notifRef}>
<a
href="#!"
className="dropdown-toggle nav-link"
onClick={(e) => {
e.preventDefault();
setOpenNotif((v) => !v);
setOpenProfile(false);
}}
>
<i className="fa fa-bell-o"></i>
<span className="badge badge-pill bg-danger float-right">2</span>
</a>
<div className={`dropdown-menu notifications${openNotif ? " show" : ""}`}>
<div className="topnav-dropdown-header">
<span>Cadastrado</span>
</div>
{/* Aqui você pode listar notificações reais */}
<div className="topnav-dropdown-footer">
<a href="#!">Mensagem</a>
</div>
</div>
</li>
{/* 👤 Perfil */}
<li className="nav-item dropdown has-arrow" ref={profileRef}>
<a
href="#!"
className="dropdown-toggle nav-link user-link"
onClick={(e) => {
e.preventDefault();
setOpenProfile((v) => !v);
setOpenNotif(false);
}}
>
<span className="user-img">
<span className="status online"></span>
</span>
<span>{profileName}</span>
<i className=""></i>
</a>
<div className={`dropdown-menu${openProfile ? " show" : ""}`}>
{/* Opções padrão */}
<a className="dropdown-item" href="#!">
Paciente
</a>
<div className="dropdown-divider"></div>
{/* Troca de perfil */}
<button className="dropdown-item" onClick={goToOtherRole}>
{isDoctor ? "Admin" : "Médico"}
</button>
</div>
</li>
</ul>
</div>
);
}
export default Navbar;

View File

@ -1,68 +0,0 @@
import '../assets/css/index.css'
import { Link } from 'react-router-dom';
function Sidebar() {
return (
<div>
<div className="sidebar" id="sidebar">
<div className="sidebar-inner slimscroll">
<div id="sidebar-menu" className="sidebar-menu">
<ul>
<li className="menu-title">Main</li>
{/*<li>
<a href="index-2.html">
<i className="fa fa-dashboard" /> <span>Dashboard</span>
</a>
</li>*/}
<li>
<Link to="/doctorlist">
<i className="fa fa-user-md" /> <span>Médicos</span>
</Link>
</li>
<li>
<Link to="/patientlist">
<i className="fa fa-wheelchair" /> <span>Pacientes</span>
</Link>
</li>
<li>
<Link to="/calendar">
<i className="fa fa-calendar" /> <span>Calendario</span>
</Link>
</li>
<li>
<Link to="/doctorschedule">
<i className="fa fa-calendar-check-o" /> <span>Agenda Médica</span>
</Link>
</li>
<li>
<Link to="/agendalist">
<i className="fa fa-stethoscope" /> <span>Consultas</span>
</Link>
</li>
{/* 🆕 Nova aba Laudo */}
<li>
<Link to="/laudolist">
<i className="fa fa-file-text" /> <span>Laudos</span>
</Link>
</li>
{/*<li>
<a href="settings.html">
<i className="fa fa-cog" /> <span>Configurações</span>
</a>
</li>*/}
</ul>
</div>
</div>
</div>
</div>
);
}
export default Sidebar;

446
src/components/VerLaudo.jsx Normal file
View File

@ -0,0 +1,446 @@
// PatientList.jsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image';
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { getAccessToken } from '../utils/auth';
import { useParams } from 'react-router-dom';
import { Card, Collapse } from "react-bootstrap"; // <-- IMPORT CORRETO
import { ChevronDown, ChevronUp } from "lucide-react";
import Select from 'react-select';
import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom';
import { useRef } from 'react';
import { InterimMark } from '../utils/InterimMark';
import { FaMicrophone } from "react-icons/fa";
import { getUserRole } from '../utils/userInfo';
function Bar({ comandos, toggleRecording, isRecording, editor, pacientesMap, Laudos }) {
const inputRef = useRef(null);
const handleAbrirExplorador = () => {
inputRef.current.click(); // abre o explorador
};
const handleArquivoSelecionado = (event) => {
const arquivo = event.target.files[0];
if (arquivo) {
const imageUrl = URL.createObjectURL(arquivo);
comandos.agregarImagen(imageUrl);
event.target.value = null;
}
};
const navigate = useNavigate();
const role = getUserRole();
return (
<>
<div className="toolbar">
<div className="left">
<button onClick={comandos.toggleBold} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 11H12.5C13.8807 11 15 9.88071 15 8.5C15 7.11929 13.8807 6 12.5 6H8V11ZM18 15.5C18 17.9853 15.9853 20 13.5 20H6V4H12.5C14.9853 4 17 6.01472 17 8.5C17 9.70431 16.5269 10.7981 15.7564 11.6058C17.0979 12.3847 18 13.837 18 15.5ZM8 13V18H13.5C14.8807 18 16 16.8807 16 15.5C16 14.1193 14.8807 13 13.5 13H8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleItalic} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 20H7V18H9.92661L12.0425 6H9V4H17V6H14.0734L11.9575 18H15V20Z"></path>
</svg>
</button>
<button onClick={comandos.toggleUnderline} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z"></path>
</svg>
</button>
{/*<button onClick={comandos.toggleCodeBlock} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
</button> */}
<button onClick={comandos.toggleH1} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 20H11V13H4V20H2V4H4V11H11V4H13V20ZM21.0005 8V20H19.0005L19 10.204L17 10.74V8.67L19.5005 8H21.0005Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH2} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4V11H11V4H13V20H11V13H4V20H2V4H4ZM18.5 8C20.5711 8 22.25 9.67893 22.25 11.75C22.25 12.6074 21.9623 13.3976 21.4781 14.0292L21.3302 14.2102L18.0343 18H22V20H15L14.9993 18.444L19.8207 12.8981C20.0881 12.5908 20.25 12.1893 20.25 11.75C20.25 10.7835 19.4665 10 18.5 10C17.5818 10 16.8288 10.7071 16.7558 11.6065L16.75 11.75H14.75C14.75 9.67893 16.4289 8 18.5 8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH3} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 8L21.9984 10L19.4934 12.883C21.0823 13.3184 22.25 14.7728 22.25 16.5C22.25 18.5711 20.5711 20.25 18.5 20.25C16.674 20.25 15.1528 18.9449 14.8184 17.2166L16.7821 16.8352C16.9384 17.6413 17.6481 18.25 18.5 18.25C19.4665 18.25 20.25 17.4665 20.25 16.5C20.25 15.5335 19.4665 14.75 18.5 14.75C18.214 14.75 17.944 14.8186 17.7056 14.9403L16.3992 13.3932L19.3484 10H15V8H22ZM4 4V11H11V4H13V20H11V13H4V20H2V4H4Z"></path>
</svg>
</button>
<button onClick={comandos.toggleParrafo} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6V21H10V16C6.68629 16 4 13.3137 4 10C4 6.68629 6.68629 4 10 4H20V6H17V21H15V6H12ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14V6Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaOrdenada} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM5 3V6H6V7H3V6H4V4H3V3H5ZM3 14V11.5H5V11H3V10H6V12.5H4V13H6V14H3ZM5 19.5H3V18.5H5V18H3V17H6V21H3V20H5V19.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaPontos} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<>
<input
type="file"
accept="image/*"
ref={inputRef}
onChange={handleArquivoSelecionado}
style={{ display: "none" }} // esconde o input
/>
<button onClick={handleAbrirExplorador}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M2.9918 21C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918ZM20 15V5H4V19L14 9L20 15ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path>
</svg>
</button>
</>
<button
onClick={toggleRecording}
className={`toolbar-button ${isRecording ? "active" : ""}`}
title={isRecording ? "Parar ditado" : "Iniciar ditado por voz"}
>
<FaMicrophone size={18} />
</button>
<button
onClick={() => {
if (!editor) return;
const html = editor.getHTML();
const printWindow = window.open('', '_blank', 'width=900,height=700');
printWindow.document.write(`<!DOCTYPE html><html><head><title>Laudo de ${pacientesMap[Laudos.patient_id]}</title></head><body>${html}</body></html>`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
}}
title="Imprimir laudo"
className="toolbar-button"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
<path d="M6 19v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2h2a1 1 0 0 0 1-1v-7a3 3 0 0 0-3-3H5a3 3 0 0 0-3 3v7a1 1 0 0 0 1 1h2zm2 2v-4h8v4H8zm10-2v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2H4v-7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v7h-2zM6 8V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2h-2V6H8v2H6z"/>
</svg>
</button>
{/*<button onClick={comandos.agregarLink} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
</button> */}
</div>
<div className="right">
<button onClick={() => navigate(`/${role}/laudolist`)} className="btnGuardar">
<span>Voltar</span>
</button>
</div>
</div>
</>
);
};
function VerLaudo() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [Laudos, setLaudos] = useState({})
const tokenUsuario = getAccessToken()
const { id } = useParams()
const role = getUserRole();
var myHeaders = new Headers();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/reports?id=eq.${id}`, requestOptions)
.then(response => response.json())
.then(result => {
// result é um array, pegue o primeiro laudo
const laudo = Array.isArray(result) ? result[0] : null;
setLaudos(laudo || {});
})
.catch(error => console.log('error', error));
}, [id]);
const editor = useEditor({
extensions: [StarterKit, Image, InterimMark],
content: Laudos?.content_html || "<p>Escreva o laudo aqui...</p>",
onUpdate: ({ editor }) => {
setLaudos({
...Laudos,
content_html: editor.getHTML()
});
}
});
const [isRecording, setIsRecording] = useState(false);
const recognitionRef = useRef(null);
const lastInsertedRef = useRef({ from: -1, to: -1, text: '' });
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Seu navegador não suporta reconhecimento de voz 😢");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "pt-BR";
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event) => {
if (!editor) return;
const result = event.results[0];
const transcript = result[0].transcript;
const last = lastInsertedRef.current;
// --- CORREÇÃO DE LÓGICA ---
// Vamos rodar a deleção como um comando SEPARADO primeiro.
if (last.from !== -1 &&
editor.state.doc.textBetween(last.from, last.to) === last.text)
{
// Roda a deleção e PARA.
editor.chain().focus()
.deleteRange({ from: last.from, to: last.to })
.run();
}
// Pega a posição ATUAL (depois da deleção)
const currentPos = editor.state.selection.from;
if (result.isFinal) {
// --- RESULTADO FINAL (PRETO) ---
// Roda a inserção final como um comando SEPARADO.
editor.chain().focus()
.insertContent(transcript + ' ')
.run();
// Reseta a Ref
lastInsertedRef.current = { from: -1, to: -1, text: '' };
} else {
// --- RESULTADO PROVISÓRIO (CINZA) ---
// Esta é a nova estratégia: "Ligar" a mark, inserir, "Desligar" a mark.
// Roda tudo como um comando SEPARADO.
editor.chain()
.focus()
.setMark('interimMark') // <-- "Pincel cinza" LIGADO
.insertContent(transcript) // <-- Insere o texto
.unsetMark('interimMark') // <-- "Pincel cinza" DESLIGADO
.run();
// Atualiza a Ref com a posição do texto cinza
lastInsertedRef.current = {
from: currentPos,
to: currentPos + transcript.length,
text: transcript
};
}
// Não precisamos mais do 'editorChain.run()' aqui embaixo
};
recognition.onerror = (err) => {
// ... (código do onerror sem mudanças)
};
recognition.onend = () => {
// ... (código do onend sem mudanças)
};
recognitionRef.current = recognition;
return () => {
recognition.stop();
};
}, [editor, isRecording]);
const toggleRecording = () => {
if (!recognitionRef.current) return;
if (isRecording) {
// Usuário clicou para PARAR
setIsRecording(false); // <-- Seta o estado
recognitionRef.current.stop(); // <-- Para a API
// O 'onend' será chamado e fará a limpeza/confirmação.
} else {
// Usuário clicou para COMEÇAR
editor?.chain().focus().run();
setIsRecording(true); // <-- Seta o estado
recognitionRef.current.start(); // <-- Inicia a API
}
};
useEffect(() => {
if (editor && Laudos?.content_html) {
editor.commands.setContent(Laudos.content_html);
}
}, [editor, Laudos]);
const comandos = {
toggleBold: () => editor.chain().focus().toggleBold().run(),
toggleItalic: () => editor.chain().focus().toggleItalic().run(),
toggleUnderline: () => editor.chain().focus().toggleUnderline().run(),
toggleCodeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
toggleH1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
toggleH2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
toggleH3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
toggleParrafo: () => editor.chain().focus().setParagraph().run(),
toggleListaOrdenada: () => editor.chain().focus().toggleOrderedList().run(),
toggleListaPontos: () => editor.chain().focus().toggleBulletList().run(),
agregarImagen: (url) => {
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
},
agregarLink: () => {
const url = window.prompt('URL do link')
if (url) {
editor.chain().focus().setLink({ href: url }).run()
}
}
}
const handleChange = (e) => {
const { name, value } = e.target;
setLaudos((prev) => ({
...prev,
[name]: value
}));
};
const [pacientesMap, setPacientesMap] = useState({});
useEffect(() => {
if (!Laudos || !Laudos.patient_id) return;
const buscarPacientes = async () => {
try {
// Pega IDs únicos de pacientes
const idsUnicos = [Laudos.patient_id];
// Faz apenas 1 fetch por paciente
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
{
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await res.json();
return { id, full_name: data[0]?.full_name || "Nome não encontrado" };
} catch (err) {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setPacientesMap(map);
} catch (err) {
console.error("Erro ao buscar pacientes:", err);
}
};
buscarPacientes();
}, [Laudos]);
return (
<div className="page-wrapper">
<div className="content">
<h4 className="page-title">Laudo Médico</h4>
<div className="d-flex flex-column align-items-left mt-5">
<Card style={{ width: "100%", borderRadius: "10px" }}>
<Card.Header
onClick={() => setOpen(!open)}
aria-controls="paciente-content"
aria-expanded={open}
className="d-flex justify-content-between align-items-center"
style={{
cursor: "pointer",
borderRadius: "25px",
padding: "12px 20px",
}}
>
<span>Informações do paciente</span>
{open ? <ChevronUp /> : <ChevronDown />}
</Card.Header>
<Collapse in={open}>
<div id="paciente-content" className="p-3">
<label>Nome do paciente</label>
<input
className="form-control mb-2"
name='patient_id'
type='text'
id='patient_id'
placeholder="Pesquisar paciente..."
value={pacientesMap[Laudos.patient_id]}
readOnly
>
</input>
<label>Diagnóstico</label>
<input
type="text"
className="form-control mb-2"
placeholder="Diagnóstico"
name='diagnosis'
id='diagnosis'
value={Laudos.diagnosis}
onChange={handleChange}
readOnly
/>
<label>Exames</label>
<input
type="text"
className="form-control mb-2"
name='exam'
id='exam'
value={Laudos.exam}
onChange={handleChange}
placeholder="Exame"
readOnly
/>
<label>Conclusão</label>
<input
type="text"
className="form-control mb-2"
name='conclusion'
id='conclusion'
value={Laudos.conclusion}
onChange={handleChange}
placeholder="Conclusão"
readOnly
/>
</div>
</Collapse>
</Card>
</div>
<Bar comandos={comandos} toggleRecording={toggleRecording} isRecording={isRecording} editor={editor} pacientesMap={pacientesMap} Laudos={Laudos} />
<EditorContent editor={editor} />
</div>
</div>
);
}
export default VerLaudo;

View File

@ -0,0 +1,206 @@
import React, { useRef, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { ZegoUIKitPrebuilt } from "@zegocloud/zego-uikit-prebuilt";
import { getFullName } from "../../utils/userInfo";
import { getPatientId } from "../../utils/userInfo";
import { getAccessToken } from "../../utils/auth";
import { getDoctorId } from "../../utils/userInfo";
import TranscriptBlock from "./TranscriptBlock"; // Componente da barra lateral
const RoomPage = () => {
  const { roomId } = useParams();
  const meetingRef = useRef(null);
  const [userInfo, setUserInfo] = useState(null);
  const [kitToken, setKitToken] = useState(null);
  const [canJoin, setCanJoin] = useState(false);
  const [consulta, setConsulta] = useState(null);
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);
 
  // Variáveis de ambiente e token
  const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
  const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
  const tokenUsuario = getAccessToken();
 
  // Variável para determinar o modo (Mobile/Desktop)
  const isMobile = windowWidth < 1024;
  // --- Efeitos e Lógica (Mantidos) ---
  useEffect(() => {
    const handleResize = () => setWindowWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  useEffect(() => {
    async function fetchUserAndValidate() {
      var myHeaders = new Headers();
      myHeaders.append("apikey", supabaseAK);
      myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
      var requestOptions = {
        method: 'GET',
        headers: myHeaders,
        redirect: 'follow'
      };
      // Busca a consulta específica pelo roomId
      const response = await fetch(`${supabaseUrl}/rest/v1/appointments?id=eq.${roomId}`, requestOptions);
      const result = await response.json();
      const consultaData = Array.isArray(result) ? result[0] : null;
      setConsulta(consultaData);
     
      const userPatient = { id: getPatientId(), name: getFullName() };
      const userDoctor = { id: getDoctorId(), name: getFullName() };
      const user = consultaData?.patient_id === userPatient.id ? userPatient : userDoctor;
     
      const isMedico = user.id === consultaData?.doctor_id;
      const uniqueId = (user.id || "") + '-' + Date.now();
      const isResponsavel = user.id === consultaData?.doctor_id || user.id === consultaData?.patient_id;
     
      if (isResponsavel) {
        setUserInfo({ name: user.name, id: uniqueId, isMedico });
      } else {
        alert('Você não tem permissão para acessar esta sala.');
      }
    }
    fetchUserAndValidate();
  }, [roomId, supabaseAK, supabaseUrl, tokenUsuario]);
  useEffect(() => {
    if (!userInfo) return;
    const appID = 1934403252;
    const serverSecret = "7290704fc5dca533b3633cf22b1a2635";
    const token = ZegoUIKitPrebuilt.generateKitTokenForTest(
      appID,
      serverSecret,
      roomId,
      userInfo.id,
      userInfo.name
    );
    setKitToken(token);
  }, [userInfo, roomId]);
  const handleJoin = () => {
    navigator.mediaDevices.getUserMedia({ video: true, audio: true })
      .then(() => {
        setCanJoin(true);
      })
      .catch(() => {
        alert('Permita o acesso à câmera e microfone para usar a videoconferência.');
      });
  };
  useEffect(() => {
    if (!kitToken || !meetingRef.current || !canJoin) return;
    const zp = ZegoUIKitPrebuilt.create(kitToken);
    zp.joinRoom({
      container: meetingRef.current,
      scenario: {
        mode: ZegoUIKitPrebuilt.VideoConference,
      },
      showPreJoinView: false,
      showRemoveUserButton: userInfo?.isMedico === true,
    });
  }, [kitToken, canJoin, userInfo]);
  // --- ESTILOS RESPONSIVOS E DE CORES ---
  const fullScreenStyle = {
    width: "100vw",
    height: "100vh",
    margin: 0,
    padding: 0,
    overflow: "hidden",
    boxSizing: 'border-box',
  };
  const callContainerStyle = isMobile ?
    {
      display: "flex",
      flexDirection: "column",
      height: "100%",
      width: "100%",
      boxSizing: 'border-box',
    } :
    {
      display: "flex",
      height: "100%",
      width: "100%",
      boxSizing: 'border-box',
    };
  // Ajuste AQUI
  const videoAreaStyle = isMobile ?
    {
      flex: 'none',
      width: "100%",
      height: "40vh", // <<< NOVO E ÚLTIMO AJUSTE: Reduzido para 40vh (dá mais folga)
      minHeight: "300px",
      boxSizing: 'border-box',
    } :
    {
      flex: 2,
      height: "100%",
      boxSizing: 'border-box',
    };
  // Ajuste AQUI
  const transcriptAreaStyle = isMobile ?
    {
      flex: 'none',
      width: "100%",
      height: "calc(100vh - 40vh)", // <<< NOVO AJUSTE: Calculado com base em 40vh
      background: "#1c1f2e",
      overflowY: "auto",
      boxSizing: 'border-box',
      padding: '16px',
    } :
    {
      flex: 1,
      minWidth: 350,
      maxWidth: 500,
      background: "#1c1f2e",
      height: "100%",
      overflowY: "auto",
      boxSizing: 'border-box',
      padding: '16px',
    };
  // --- RENDERIZAÇÃO (Mantida) ---
  return (
    <div style={fullScreenStyle}>
      <div style={{ width: "100%", height: "100%" }}>
        {!canJoin ? (
          // Tela de Permissão de Câmera/Microfone
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%" }}>
            <h2>Permita o acesso à câmera e microfone para entrar na chamada</h2>
            <button onClick={handleJoin} style={{ padding: "12px 32px", fontSize: "1.2rem", borderRadius: "8px", background: "#1976d2", color: "#fff", border: "none", cursor: "pointer", marginTop: "24px" }}>
              Entrar na chamada
            </button>
          </div>
        ) : (
          // Tela da Chamada com Layout Responsivo
          <div style={callContainerStyle}>
            {/* Área de Vídeo (Zego Cloud) */}
            <div ref={meetingRef} style={videoAreaStyle} />
           
            {/* Área de Transcrição/Bloco de Texto */}
            <div style={transcriptAreaStyle}>
              {consulta && (
                <TranscriptBlock
                  appointmentId={roomId}
                  doctor_id={consulta.doctor_id}
                  patient_id={consulta.patient_id}
                  exam={consulta.chief_complaint}
                />
              )}
            </div>
          </div>
        )}
      </div>
    </div>
  );
};
export default RoomPage;

View File

@ -0,0 +1,268 @@
import React, { useState, useRef, useEffect } from "react";
import { getAccessToken } from "../../utils/auth";
import { useNavigate } from "react-router-dom";
import { getDoctorId } from "../../utils/userInfo";
import Swal from "sweetalert2";
import { getUserRole } from "../../utils/userInfo";
const TranscriptBlock = ({ appointmentId, doctor_id, patient_id, exam }) => {
const [transcript, setTranscript] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const recognitionRef = useRef(null);
const navigate = useNavigate();
const [laudos, setLaudos] = useState({
patient_id: patient_id,
order_number: "",
exam: exam || "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: transcript,
status: "draft",
requested_by: doctor_id,
});
const isMedico = getDoctorId() === doctor_id;
const Change = (e) => {
const { name, value } = e.target;
setLaudos((prev) => ({ ...prev, [name]: value }));
};
const handleChange = (event) => {
setTranscript(event.target.value);
};
useEffect(() => {
setLaudos((prev) => ({
...prev,
content_html: transcript
}));
}, [transcript]);
const handleStartStop = () => {
if (!isRecording) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Seu navegador não suporta reconhecimento de voz.");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "pt-BR";
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
let interim = "";
let final = "";
for (let i = 0; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
final += event.results[i][0].transcript;
} else {
interim += event.results[i][0].transcript;
}
}
setTranscript((prev) => final + interim);
};
recognition.onend = () => setIsRecording(false);
recognition.onerror = () => setIsRecording(false);
recognitionRef.current = recognition;
recognition.start();
setIsRecording(true);
} else {
recognitionRef.current && recognitionRef.current.stop();
setIsRecording(false);
}
};
const handleSaveAsLaudo = async () => {
setIsSaving(true);
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const tokenUsuario = getAccessToken();
try {
const response = await fetch(`${supabaseUrl}/rest/v1/reports`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"apikey": supabaseAK,
"Authorization": `Bearer ${tokenUsuario}`
},
body: JSON.stringify({ ...laudos })
});
if (response.ok) {
await Swal.fire({
icon: 'success',
title: 'Laudo salvo com sucesso!',
showConfirmButton: false,
timer: 1800
});
} else {
const error = await response.text();
await Swal.fire({
icon: 'error',
title: 'Erro ao salvar o laudo',
text: error,
});
console.log("Payload enviado:", laudos);
}
} catch (err) {
await Swal.fire({
icon: 'error',
title: 'Erro ao salvar o laudo',
text: err.message || '',
});
}
setIsSaving(false);
};
const handleCorrigirComIA = async () => {
setIsSaving(true);
try {
const response = await fetch(import.meta.env.VITE_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{ role: "user", parts: [{ text: `Corrija erros de português, termos médicos e pontuação neste texto de transcrição de consulta médica, mantendo o sentido original e sem inventar informações. Responda apenas com o texto corrigido.\n\n${transcript}` }] }
]
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error?.message || "Erro ao corrigir com IA");
const textoCorrigido = data.candidates?.[0]?.content?.parts?.[0]?.text || transcript;
setTranscript(textoCorrigido.trim());
await Swal.fire({
icon: 'success',
title: 'Texto corrigido com IA!',
showConfirmButton: false,
timer: 1500
});
} catch (err) {
await Swal.fire({
icon: 'error',
title: 'Erro ao corrigir com IA',
text: err.message || '',
});
}
setIsSaving(false);
};
// NOVO: Finalizar consulta
const handleFinalizarConsulta = async () => {
setIsSaving(true);
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const tokenUsuario = getAccessToken();
try {
await fetch(`${supabaseUrl}/rest/v1/appointments?id=eq.${appointmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"apikey": supabaseAK,
"Authorization": `Bearer ${tokenUsuario}`
},
body: JSON.stringify({ status: "completed" })
});
await Swal.fire({
icon: 'success',
title: 'Consulta finalizada!',
showConfirmButton: false,
timer: 1800
});
navigate(`/${getUserRole()}/consultalist`); // Altere para o caminho do seu menu/dash se necessário
} catch (err) {
await Swal.fire({
icon: 'error',
title: 'Erro ao finalizar consulta',
text: err.message || '',
});
}
setIsSaving(false);
};
const buttonStyle = {
padding: "10px 24px",
fontSize: "1rem",
borderRadius: "8px",
background: isRecording ? '#e74c3c' : '#1976d2',
color: "#fff",
border: "none",
cursor: "pointer",
marginTop: "12px",
marginRight: "8px",
transition: "background 0.2s"
};
const secondaryButtonStyle = {
...buttonStyle,
background: "#fff",
color: "#1976d2",
border: "1px solid #1976d2"
};
return (
<div style={{ background: "#1c1f2e", padding: "32px 24px", margin: 0, minHeight: 0, height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "stretch", flex: 1, border: 'none' }}>
<h3 style={{ marginBottom: 8, color: "#1976d2", fontWeight: 600 }}>
Transcrição / Bloco de Texto
</h3>
<p style={{ color: "#aaa", marginBottom: 16, fontSize: 14 }}>
Você pode digitar, colar, editar manualmente, usar o microfone para transcrever ou corrigir com IA.
</p>
<button
onClick={handleStartStop}
style={buttonStyle}
onMouseOver={e => e.currentTarget.style.background = isRecording ? '#c0392b' : '#1565c0'}
onMouseOut={e => e.currentTarget.style.background = isRecording ? '#e74c3c' : '#1976d2'}
>
{isRecording ? "Parar Transcrição" : "Iniciar Transcrição por Voz"}
</button>
<button
onClick={handleCorrigirComIA}
style={{ ...buttonStyle, background: isSaving ? '#bdbdbd' : '#ff9800', marginTop: 12 }}
disabled={isSaving || !transcript.trim()}
>
{isSaving ? 'Aguarde...' : 'Corrigir com IA'}
</button>
<textarea
value={transcript}
onChange={handleChange}
rows={10}
style={{ width: "100%", padding: "14px", fontSize: "1rem", marginTop: 12, marginBottom: 12, background: '#23263a', color: '#fff', minHeight: 180, borderRadius: 8, border: '1px solid #444' }}
placeholder="Digite ou cole aqui o texto do laudo ou transcrição..."
/>
<input
type="text"
name="diagnosis"
value={laudos.diagnosis}
onChange={Change}
style={{ width: "100%", padding: "12px", fontSize: "1rem", marginBottom: 12, background: '#23263a', color: '#fff', borderRadius: 8, border: '1px solid #444' }}
placeholder="Diagnóstico"
/>
<input
type="text"
name="conclusion"
value={laudos.conclusion}
onChange={Change}
style={{ width: "100%", padding: "12px", fontSize: "1rem", marginBottom: 12, background: '#23263a', color: '#fff', borderRadius: 8, border: '1px solid #444' }}
placeholder="Conclusão"
/>
<button
onClick={handleSaveAsLaudo}
style={{ ...buttonStyle, background: isSaving ? '#bdbdbd' : '#388e3c', marginTop: 24 }}
disabled={isSaving || !transcript.trim()}
>
{isSaving ? 'Salvando...' : 'Salvar como Laudo'}
</button>
{isMedico && (
<button
onClick={handleFinalizarConsulta}
style={{ ...buttonStyle, background: '#ff2c2c ', marginTop: 16 }}
disabled={isSaving}
>
Finalizar Consulta
</button>
)}
</div>
);
};
export default TranscriptBlock;

262
src/components/chat.jsx Normal file
View File

@ -0,0 +1,262 @@
import React, { useState, useRef, useEffect, use } from 'react';
import './chatpanel.css';
const AvatarForm = "/img/AvatarForm.jpg";
import { getAccessToken } from '../utils/auth';
import { getUserId } from '../utils/userInfo';
import { getUserRole } from '../utils/userInfo';
import { getPatientId} from '../utils/userInfo';
import { getDoctorId } from '../utils/userInfo';
import { createClient } from '@supabase/supabase-js';
const Chat = () => {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const messagesEndRef = useRef(null);
const [allContacts, setAllContacts] = useState([]);
const [selectedContact, setSelectedContact] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const chatURL = import.meta.env.VITE_CHAT_SERVICE_URL;
const chatKEY = import.meta.env.VITE_CHAT_ANO_KEY;
const tokenUsuario = getAccessToken();
const userRole = getUserRole(); // 'medico' ou 'paciente'
const selectedContactObj = allContacts.find(c => c.id === selectedContact);
const [myDoctorId, setMyDoctorId] = useState(null);
const [myPatientId, setMyPatientId] = useState(null);
// Always scroll to the latest message
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
// Buscar doctor_id ou patient_id do usuário logado
useEffect(() => {
setMyDoctorId(getDoctorId());
setMyPatientId(getPatientId());
}, []);
// Alerta só se já tentou buscar o id (evita alerta no render inicial)
const [checkedIds, setCheckedIds] = useState(false);
useEffect(() => {
setMyDoctorId(getDoctorId());
setMyPatientId(getPatientId());
setCheckedIds(true);
}, []);
useEffect(() => {
if (!checkedIds) return;
if (userRole === 'medico' && myDoctorId === null) {
alert('Não foi possível encontrar seu doctor_id. Verifique seu cadastro ou contate o suporte.');
}
if (userRole === 'paciente' && myPatientId === null) {
alert('Não foi possível encontrar seu patient_id. Verifique seu cadastro ou contate o suporte.');
}
}, [myDoctorId, myPatientId, userRole, checkedIds]);
// Ensure chatId is always doctorId first, patientId second
let doctorId, patientId;
if (userRole === 'medico') {
doctorId = myDoctorId;
patientId = selectedContactObj?.id;
} else {
doctorId = selectedContactObj?.id;
patientId = myPatientId;
}
// Only set chatId if both are defined
const chatId = doctorId && patientId ? `chat_${doctorId}_${patientId}` : null;
// Supabase da empresa (para pacientes/doctors)
const supabaseEmpresa = createClient(supabaseUrl, supabaseAK);
// Seu Supabase (para chat)
const supabaseChat = createClient(chatURL, chatKEY);
useEffect(() => {
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
Promise.all([
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions).then(res => res.json()),
fetch(`${supabaseUrl}/rest/v1/doctors`, requestOptions).then(res => res.json())
]).then(([patients, medicos]) => {
const all = [
...patients.map(p => ({ ...p, type: 'paciente' })),
...medicos.map(m => ({ ...m, type: 'medico' }))
];
setAllContacts(all);
// Only set selectedContact if not already set and contacts exist
if (!selectedContact && all.length > 0) setSelectedContact(all[0].id);
}).catch(error => console.log('error', error));
}, [selectedContact]);
const [text, setText] = useState('');
// Carrega mensagens antigas
const loadMessages = async () => {
if (!chatId) return;
const { data } = await supabaseChat
.from('chat')
.select('*')
.eq('chat_id', chatId)
.order('created_at', { ascending: true });
console.log('Loaded messages for chatId', chatId, data);
setMessages(data || []);
};
// Listener realtime
useEffect(() => {
if (!chatId) return;
loadMessages();
const channel = supabaseChat
.channel(`chat:${chatId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'chat',
filter: `chat_id=eq.${chatId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new]);
}
)
.subscribe();
return () => supabaseChat.removeChannel(channel);
}, [chatId]);
const sendMessage = async (e) => {
e.preventDefault();
if (!input.trim() || !chatId || !selectedContactObj || !doctorId || !patientId) {
console.log('Missing required fields', {input, chatId, selectedContactObj, doctorId, patientId});
return;
}
console.log('Sending message', {
chat_id: chatId,
sender_id: userRole === 'medico' ? doctorId : patientId,
receiver_id: selectedContactObj.id,
doctor_id: doctorId,
patient_id: patientId,
content: input,
});
const { error } = await supabaseChat.from('chat').insert({
chat_id: chatId,
sender_id: userRole === 'medico' ? doctorId : patientId,
receiver_id: selectedContactObj.id,
doctor_id: doctorId,
patient_id: patientId,
content: input,
});
if (error) {
console.error('Supabase insert error:', error);
alert('Erro ao enviar mensagem: ' + error.message);
}
setInput('');
};
// Filter contacts: doctors see only patients, patients see only doctors
const filteredContacts = userRole === 'medico'
? allContacts.filter(c => c.type === 'paciente')
: allContacts.filter(c => c.type === 'medico');
// Set initial selected contact from filtered list
useEffect(() => {
if (!selectedContact && filteredContacts.length > 0) {
setSelectedContact(filteredContacts[0].id);
}
// eslint-disable-next-line
}, [filteredContacts]);
console.log('userRole:', userRole);
console.log('filteredContacts:', filteredContacts);
console.log('selectedContact:', selectedContact, 'selectedContactObj:', selectedContactObj);
console.log('doctorId:', doctorId, 'patientId:', patientId, 'chatId:', chatId);
return (
<div className='main-wrapper'>
<div className="page-wrapper">
<div className="content">
<div className="chatpanel-container">
<div className="chatpanel-contacts">
<h2 style={{ textAlign: 'center', margin: '18px 0 10px', color: '#004a99', fontWeight: 700, fontSize: '1.3rem' }}>Contatos</h2>
<input
type="text"
className="contacts-search"
placeholder={userRole === 'medico' ? "Buscar paciente..." : "Buscar médico..."}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
style={{ margin: '0 12px 10px', padding: '8px 12px', borderRadius: '20px', border: '1px solid #ccc', fontSize: '1rem', outline: 'none' }}
/>
<div className="contacts-list">
{filteredContacts.filter(p => p.full_name?.toLowerCase().includes(searchTerm.toLowerCase())).map((contact) => (
<div
key={contact.id}
className={`chatpanel-contact${selectedContact === contact.id ? ' selected' : ''}`}
onClick={() => setSelectedContact(contact.id)}
>
<img
src={AvatarForm}
alt={contact.full_name}
className="chatpanel-contact-avatar"
style={{ width: 38, height: 38, borderRadius: '50%', objectFit: 'cover', marginRight: 12 }}
onError={e => { e.target.src = AvatarForm; }}
/>
<span style={{ fontWeight: 500, fontSize: '1.08rem' }}>{contact.full_name}</span>
</div>
))}
</div>
</div>
<div className="chatpanel-chat">
<div className="chatpanel-header">
<img className="chatpanel-avatar"
src={AvatarForm}
style={{
marginRight: "10px",
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover"
}}
onError={(e) => {
e.target.src = AvatarForm;
}}
/>
{filteredContacts.find((p) => p.id === selectedContact)?.full_name}
<input type="text" className='search' placeholder="🔍 Buscar mensagens" />
</div>
<div className="chatpanel-messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`chatpanel-bubble ${msg.sender_id === (userRole === 'medico' ? myDoctorId : myPatientId) ? 'me' : 'other'}`}
>
{msg.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<form className="chatpanel-input-area" onSubmit={sendMessage}>
<input
type="text"
className="chatpanel-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Digite uma mensagem"
/>
<button type="submit" className="chatpanel-send-btn">Enviar</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default Chat;

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import useResponsive from '../utils/useResponsive';
import { useState } from 'react';

View File

@ -0,0 +1,33 @@
import { useRef } from "react";
const ChatForm = ({chatHistory, setChatHistory, generateBotResponse}) => {
const inputRef = useRef();
const HandleFormSubmit = (e) =>{
e.preventDefault();
const userMessage = inputRef.current.value.trim();
if (!userMessage) return;
inputRef.current.value= "";
setChatHistory(history => [...history, {role: "user", text: userMessage}]);
setTimeout(()=> {
setChatHistory(history => [...history, {role: "model", text: "Pensando..."}]);
generateBotResponse([...chatHistory, {role: "user", text: userMessage}]);
}, 600);
}
return (
<form action="#" className="chat-form" onSubmit={HandleFormSubmit}>
<input ref={inputRef} type="text" placeholder="Mensagem..."
className="message-input" required />
<button className="material-symbols-rounded">
arrow_upward
</button>
</form>
)
}
export default ChatForm

View File

@ -0,0 +1,15 @@
import ChatbotIcon from "./ChatbotIcon";
const ChatMessage = ({chat}) => {
return (
!chat.hideInchat && (
<div className={`message ${chat.role === "model" ? 'bot' : 'user'}-message ${chat.isError ? "error" :""}`}>
{chat.role === "model" && <ChatbotIcon />}
<p className="message-text">
{chat.text}
</p>
</div>
)
);
}
export default ChatMessage;

View File

@ -0,0 +1,14 @@
const ChatbotIcon = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"
viewBox="0 0 1024 1024">
<path d="M738.3 287.6H285.7c-59 0-106.8 47.8-106.8 106.8v303.1c0 59 47.8 106.8 106.8 106.8h81.5v111.1c0 .7.8 1.1 1.4.7l166.9-110.6 41.8-.8h117.4l43.6-.4c59 0 106.8-47.8 106.8-106.8V394.5c0-59-47.8-106.9-106.8-106.9zM351.7 448.2c0-29.5 23.9-53.5 53.5-53.5s53.5 23.9 53.5 53.5-23.9 53.5-53.5 53.5-53.5-23.9-53.5-53.5zm157.9 267.1c-67.8 0-123.8-47.5-132.3-109h264.6c-8.6 61.5-64.5 109-132.3 109zm110-213.7c-29.5 0-53.5-23.9-53.5-53.5s23.9-53.5 53.5-53.5 53.5 23.9 53.5 53.5-23.9 53.5-53.5 53.5zM867.2 644.5V453.1h26.5c19.4 0 35.1 15.7 35.1 35.1v121.1c0 19.4-15.7 35.1-35.1 35.1h-26.5zM95.2 609.4V488.2c0-19.4 15.7-35.1 35.1-35.1h26.5v191.3h-26.5c-19.4 0-35.1-15.7-35.1-35.1zM561.5 149.6c0 23.4-15.6 43.3-36.9 49.7v44.9h-30v-44.9c-21.4-6.5-36.9-26.3-36.9-49.7 0-28.6 23.3-51.9 51.9-51.9s51.9 23.3 51.9 51.9z" />
</svg>
)
}
export default ChatbotIcon;

View File

@ -0,0 +1,842 @@
import ChatbotIcon from "./ChatbotIcon"
import './../../assets/css/index.css'
import ChatForm from "./ChatForm";
import ChatMessage from "./ChatMessage";
import { useState, useRef, useEffect } from "react";
import { Company } from "../../Company";
import { getAccessToken } from "../../utils/auth";
import { getPatientId } from "../../utils/userInfo";
// --- MAPAS DE TRADUÇÃO ---
const dbWeekdayToPt = {
"monday": "Segunda-feira",
"tuesday": "Terca-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo"
};
const ptWeekdayToDb = {
"Domingo": "sunday",
"Segunda-feira": "monday",
"Terca-feira": "tuesday",
"Quarta-feira": "wednesday",
"Quinta-feira": "thursday",
"Sexta-feira": "friday",
"Sábado": "saturday"
};
// --- FUNÇÕES HELPER PARA HORÁRIOS ---
// Converte horário "HH:MM:SS" para minutos
function timeToMinutes(timeString) {
const [hours, minutes] = timeString.split(':').map(Number);
return hours * 60 + minutes;
}
// Converte minutos de volta para "HH:MM:SS"
function minutesToTime(minutes) {
const hours = Math.floor(minutes / 60).toString().padStart(2, '0');
const mins = (minutes % 60).toString().padStart(2, '0');
return `${hours}:${mins}:00`;
}
function Chatbox() {
const token = getAccessToken();
const [chatHistory, setChatHistory] = useState([{
hideInchat: true,
role: "model",
text: Company,
}, {
hideInchat: true,
role: "model",
text: `Você é um assistente médico virtual inteligente da MediConnect. Suas responsabilidades:
**IMPORTANTE:** Você NUNCA deve prescrever medicamentos, dar diagnósticos definitivos ou substituir consultas médicas presenciais.
1. **QUANDO USUÁRIO MENCIONAR SINTOMAS/DOENÇAS:**
- Seja empático e compreensivo
- Forneça informações educativas gerais sobre o sintoma/condição
- dicas de cuidados básicos e prevenção (quando aplicável)
- Explique quando é importante buscar ajuda médica
- SEMPRE recomende consultar um profissional para avaliação adequada
- **SEMPRE pergunte se o usuário quer agendar** de forma natural
- Termine com: "RECOMENDO_CONSULTA: [especialidade]" para ativar detecção de resposta
2. **ESPECIALIDADES DISPONÍVEIS:**
- **cardiologia**: problemas cardíacos, dor no peito, palpitações, pressão alta
- **dermatologia**: problemas de pele, acne, manchas, alergias cutâneas
- **ortopedia**: dores articulares, problemas ósseos, lesões musculares
- **pediatria**: saúde infantil, desenvolvimento, vacinação
- **ginecologia**: saúde feminina, menstruação, gravidez
- **clinico geral**: sintomas gerais, febre, gripe, check-ups
- **oftalmologia**: problemas de visão, saúde ocular
- **psiquiatria**: saúde mental, ansiedade, depressão
3. **EXEMPLO DE RESPOSTA ADEQUADA:**
Usuário: "Estou com dor no peito e palpitações"
Você: "Entendo sua preocupação. Dor no peito e palpitações podem ter várias causas, desde ansiedade até problemas cardíacos mais sérios.
**Algumas informações importantes:**
- Esses sintomas merecem atenção médica, especialmente se persistirem
- Evite esforços físicos intensos até ser avaliado
- Se a dor for muito intensa ou acompanhada de falta de ar, procure emergência
- Mantenha-se calmo e respire profundamente
**É fundamental que um cardiologista avalie esses sintomas** para determinar a causa e orientar o tratamento adequado. Gostaria que eu ajude você a agendar uma consulta com um cardiologista?
RECOMENDO_CONSULTA: cardiologia"
4. **OUTRAS DICAS GERAIS:**
- Para sintomas leves: hidratação, repouso, alimentação saudável
- Para prevenção: exercícios regulares, dieta balanceada, sono adequado
- Sempre mencione sinais de alarme que requerem atendimento imediato
- Seja claro que suas orientações são educativas, não médicas
5. **NUNCA FAÇA:**
- Não prescreva medicamentos específicos
- Não diagnósticos definitivos
- Não minimize sintomas graves
- Não substitua a consulta médica
6. **OUTRAS FUNÇÕES:**
- Ajudar com informações sobre a clínica
- Auxiliar em agendamentos
- Responder dúvidas administrativas
- Fornecer orientações gerais de saúde`
}]);
const [showChatbot, setShowChatbot] = useState(false);
const chatBodyRef = useRef()
// --- NOSSOS DADOS DE CACHE ---
const [disponibilidadeMedicos, setDisponibilidadeMedicos] = useState([]);
const [MedicosMap, setMedicosMap] = useState({});
const [allDoctors, setAllDoctors] = useState([]); // Cache para todos os médicos
// Estado da conversa para o fluxo de agendamento
const [conversationState, setConversationState] = useState({
flow: null,
step: null,
data: {}
});
// Helper para limpar o estado
const resetConversation = () => {
setConversationState({ flow: null, step: null, data: {} });
};
// O ID do usuário logado (usado para criar agendamento)
// ATENÇÃO: TROQUE ESTE ID POR eu UM ID DE PACIENTE QUE EXISTA NO SEU BANCO
//6e7f8829-0574-42df-9290-8dbb70f75ada - jp
const patient_id = getPatientId();
const user = { id: patient_id };
// --- EFEITOS PARA BUSCAR DADOS (CACHE) ---
// 1) Buscar doctor_availability (BLOCO DE DIAS) UMA ÚNICA VEZ
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
useEffect(() => {
if (!token) return;
const buscarDisponibilidade = async () => {
try {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
apikey: supabaseAK,
};
const res = await fetch(`${supabaseUrl}/rest/v1/doctor_availability`, { method: "GET", headers });
if (!res.ok) throw new Error("Erro ao buscar disponibilidade");
const result = await res.json();
setDisponibilidadeMedicos(Array.isArray(result) ? result : []);
} catch (err) {
console.error("Erro na requisição de disponibilidade:", err);
}
};
buscarDisponibilidade();
}, [token]);
// 2) Buscar TODOS os médicos UMA ÚNICA VEZ
useEffect(() => {
if (!token) return;
const fetchAllDoctors = async () => {
try {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
apikey: supabaseAK,
};
const res = await fetch(`${supabaseUrl}/rest/v1/doctors`, { method: "GET", headers });
if (!res.ok) throw new Error("Erro ao buscar médicos");
const data = await res.json();
setAllDoctors(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Erro ao buscar todos os médicos:", err);
}
};
fetchAllDoctors();
}, [token]);
// 3) Quando disponibilidadeMedicos chegar, buscar nomes (para o 'MedicosMap')
useEffect(() => {
if (!Array.isArray(disponibilidadeMedicos) || disponibilidadeMedicos.length === 0 || !token) return;
const buscarMedicos = async () => {
try {
const idsUnicos = [...new Set(disponibilidadeMedicos.map((d) => d.doctor_id).filter(Boolean))];
if (idsUnicos.length === 0) return;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
apikey: supabaseAK,
};
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`, { method: "GET", headers });
if (!res.ok) return { id, full_name: "Nome não encontrado" };
const data = await res.json();
return { id, full_name: data?.[0]?.full_name || "Nome não encontrado" };
} catch {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setMedicosMap(map);
} catch (err) {
console.error("Erro ao buscar nomes dos médicos:", err);
}
};
buscarMedicos();
}, [disponibilidadeMedicos, token]);
// --- LÓGICA DO CHATBOT ---
const intents = [
// INTENT ORIGINAL: listarinformaçoes
{
name: "listarinformaçoes",
keywords: ["listar", "ver", "analisar", "mostrar", "mostre", "quais", "exibir", "exiba"],
entities: [
{ name: "paciente", values: ["pacientes", "paciente", "pessoas", "pessoa"] },
{ name: "medico", values: ["médicos", "medicos", "médico", "medico", "doutores", "profissionais", "especialidades"] },
{ name: "horario", values: ["horários", "horarios", "horário", "horario", "disponibilidade", "disponiveis", "disponíveis", "disponível", "disponivel"] }
],
action: async (lastMessage, updateHistory, matchedEntity) => {
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"apiKey": supabaseAK,
};
switch (matchedEntity.name) {
case "paciente":
try {
const response = await fetch(`${supabaseUrl}/rest/v1/patients`, { method: "GET", headers });
const data = await response.json();
if (!response.ok) throw new Error(data.error.message || "Erro ao buscar pacientes.");
if (data.length === 0) {
updateHistory("Nenhum paciente encontrado.");
} else {
const pacientesList = data.map(paciente => `- ${paciente.full_name} `).join("\n");
updateHistory(`Pacientes cadastrados:\n${pacientesList}`);
}
} catch (error) {
updateHistory(`Erro: ${error.message}`, true);
}
break;
case "medico":
try {
const response = await fetch(`${supabaseUrl}/rest/v1/doctors`, { method: "GET", headers });
const data = await response.json();
if (!response.ok) throw new Error(data.error.message || "Erro ao buscar medicos.");
if (data.length === 0) {
updateHistory("Nenhum medico encontrado.");
} else {
const medicosList = data.map(medicos => `- ${medicos.full_name} \n Especialidade:${medicos.specialty} \n `).join("\n");
updateHistory(`Medicos cadastrados:\n${medicosList}`);
}
} catch (error) {
updateHistory(`Erro: ${error.message}`, true);
}
break;
case "horario":
try {
// Reutiliza o cache que já buscamos
if (disponibilidadeMedicos.length === 0) {
updateHistory("Nenhum horário de médico encontrado.");
} else {
const horariosList = disponibilidadeMedicos.map(horario => {
const nomeMedico = MedicosMap[horario.doctor_id] || "Médico desconhecido";
const diaPt = dbWeekdayToPt[horario.weekday.toLowerCase()] || horario.weekday;
return `- ${nomeMedico}\n Dia: ${diaPt}\n Horário: ${horario.start_time} às ${horario.end_time}`;
}).join("\n");
updateHistory(`Horarios:\n${horariosList}`);
}
} catch (error) {
updateHistory(`Erro: ${error.message}`, true);
}
break;
default:
updateHistory("Não entendi o que você quer listar. Você pode pedir por pacientes, médicos, ou horários")
}
},
},
// INTENT ORIGINAL: iniciarcadastro
{
name: "iniciarcadastro",
keywords: ["cadastrar", "adicionar", "criar", "registrar", "incluir", "novo"],
entities: [
{ name: "paciente", values: ["pacientes", "paciente", "pessoa", "pessoas", "cliente", "clientes"] }
],
action: async (lastMessage, updateHistory, matchedEntity) => {
switch (matchedEntity.name) {
case "paciente":
updateHistory(
"Beleza! 🩺 Me envie os dados do paciente assim:\n\n👉 nome: João; cpf: 12345678900; telefone: 11999999999; email: joao@email.com; data de nascimento:24/09/2006"
);
break
default:
updateHistory("Não entendi o que você deseja cadastrar. Por favor, seja mais específico.")
}
}
},
// INTENT ORIGINAL: salvarPaciente
{
name: "salvarPaciente",
condition: (text) => text.includes("nome:") && text.includes("cpf:"),
action: async (lastMessage, updateHistory) => {
try {
const cleanedMessage = lastMessage.replace(/\r?\n/g, ' ');
const nome = lastMessage.match(/nome:\s*([^;]+)/)?.[1]?.trim();
const cpf = lastMessage.match(/cpf:\s*([^;]+)/)?.[1]?.trim();
const telefoneRaw = cleanedMessage.match(/telefone:\s*([^\s;]+)/i)?.[1]?.trim();
const telefone = telefoneRaw ? telefoneRaw.replace(/\D/g, '') : null;
const email = lastMessage.match(/email:\s*([^;]+)/)?.[1];
const birth_date_raw = lastMessage.match(/data de nascimento:\s*([^;]+)/)?.[1]?.trim();
let birth_date_formatted = null;
if (birth_date_raw) {
const separator = birth_date_raw.includes('/') ? '/' : '-';
const parts = birth_date_raw.split(separator);
if (parts.length === 3) {
const [dd, mm, yyyy] = parts;
birth_date_formatted = `${yyyy}-${mm}-${dd}`;
} else {
birth_date_formatted = null;
}
}
const response = await fetch(`${supabaseUrl}/rest/v1/patients`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"apikey": supabaseAK,
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
full_name: nome,
cpf: cpf,
phone_mobile: telefone,
email: email,
birth_date: birth_date_formatted
}),
});
if (!response.ok) throw new Error("Erro ao cadastrar paciente 😢");
updateHistory(`Paciente ${nome} cadastrado com sucesso! 🩺✨`);
} catch (error) {
updateHistory(error.message, true);
}
},
},
// NOVO INTENT: Iniciar Agendamento
{
name: "iniciarAgendamento",
keywords: ["agendar", "marcar", "consulta", "consultar", "agendamento"],
action: async (lastMessage, updateHistory) => {
// 1. Ligar a máquina de estados
setConversationState({
flow: 'booking',
step: 'awaiting_specialty',
data: {}
});
// 2. Fazer a primeira pergunta
updateHistory("Ótimo! Para qual especialidade você gostaria de marcar a consulta?");
}
},
];
// Função para lidar com a confirmação de agendamento
const handleBookingConfirmation = async (message, updateHistory) => {
const { step, data } = conversationState;
if (step === 'awaiting_confirmation') {
const response = message.trim().toLowerCase();
if (response.includes('sim') || response === 's' || response === 'yes' || response === 'ok') {
updateHistory("Perfeito! Vamos agendar sua consulta.");
startAutomaticBooking(data.specialty, updateHistory);
} else if (response.includes('não') || response.includes('nao') || response === 'n' || response === 'no') {
updateHistory("Sem problemas! Se precisar agendar posteriormente, pode entrar em contato conosco pelos nossos canais de atendimento. Estou aqui se tiver outras dúvidas! 😊");
resetConversation();
} else {
updateHistory("Por favor, responda com 'sim' se deseja agendar a consulta ou 'não' se prefere não agendar agora.");
}
}
};
// ATUALIZADO: A "Máquina de Estados" com a CORREÇÃO FINAL (datetime e fuso horário)
const handleBookingFlow = async (message, updateHistory) => {
const { step, data } = conversationState;
// Headers para as chamadas (incluindo a Edge Function)
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"apikey": supabaseAK,
};
if (message.includes("cancelar")) {
updateHistory("Entendido. Processo de agendamento cancelado.");
resetConversation();
return;
}
switch (step) {
// ETAPA 1: Usuário digitou a especialidade (Sem mudança)
case 'awaiting_specialty':
const specialty = message.trim().toLowerCase();
updateHistory(`Ok, buscando médicos para: ${specialty}...`);
const filteredDoctors = allDoctors.filter(doc =>
doc.specialty && doc.specialty.toLowerCase() === specialty
);
if (filteredDoctors.length === 0) {
updateHistory(`Desculpe, não encontrei médicos para "${specialty}". Por favor, digite outra especialidade ou "cancelar".`);
return;
}
const doctorListText = filteredDoctors.map((doc, i) => `${i + 1}. ${doc.full_name}`).join('\n');
updateHistory(`Encontrei estes médicos. Qual você prefere? (Digite o nome ou o número):\n${doctorListText}`);
setConversationState({
flow: 'booking',
step: 'awaiting_doctor_choice',
data: { specialty, doctorsList: filteredDoctors }
});
break;
// ETAPA 2: Usuário escolheu o médico (Sem mudança)
case 'awaiting_doctor_choice':
const choice = message.trim();
const chosenDoctor = data.doctorsList.find(doc =>
doc.full_name.toLowerCase().includes(choice.toLowerCase()) ||
choice === (data.doctorsList.indexOf(doc) + 1).toString()
);
if (!chosenDoctor) {
updateHistory("Não entendi. Por favor, digite o nome ou o número do médico da lista.");
return;
}
const availableWeekdaysDb = [...new Set(
disponibilidadeMedicos
.filter(slot => slot.doctor_id === chosenDoctor.id)
.map(slot => slot.weekday.toLowerCase())
)];
if (availableWeekdaysDb.length === 0) {
updateHistory(`Desculpe, parece que ${chosenDoctor.full_name} não tem nenhum horário cadastrado no momento. Por favor, escolha outro médico da lista.`);
setConversationState({
...conversationState,
step: 'awaiting_doctor_choice',
data: { ...data, selectedDoctor: null }
});
return;
}
const weekdaysText = availableWeekdaysDb.map(day => dbWeekdayToPt[day] || day).join(', ');
updateHistory(`Ótima escolha. O(A) Dr(a). ${chosenDoctor.full_name} atende nos seguintes dias: **${weekdaysText}**. \n\nQual data (DD/MM/AAAA), em um desses dias, você gostaria de marcar?`);
setConversationState({
...conversationState,
step: 'awaiting_date',
data: { ...data, selectedDoctor: chosenDoctor, availableWeekdaysDb: availableWeekdaysDb }
});
break;
// ETAPA 3: Usuário digitou a data (COM A CORREÇÃO FINAL)
case 'awaiting_date':
const dateInput = message.trim();
const parts = dateInput.split('/');
if (parts.length !== 3 || parts[2].length < 4) {
updateHistory("Formato de data inválido. Por favor, use DD/MM/AAAA (ex: 23/10/2025).");
return;
}
const [day, month, year] = parts;
const isoDateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
const selectedDate = new Date(isoDateStr + "T12:00:00Z");
if (isNaN(selectedDate.getTime())) {
updateHistory("Data inválida. Por favor, use DD/MM/AAAA (ex: 23/10/2025).");
return;
}
const weekdaysPtMap = ["Domingo", "Segunda-feira", "Terca-feira", "Quarta-feira", "Quinta-feira", "Sexta-feira", "Sábado"];
const ptWeekday = weekdaysPtMap[selectedDate.getUTCDay()];
const dbCheckWeekday = (ptWeekdayToDb[ptWeekday] || "").toLowerCase();
if (!data.availableWeekdaysDb.includes(dbCheckWeekday)) {
const ptWeekdaysForDisplay = data.availableWeekdaysDb.map(day => dbWeekdayToPt[day] || day).join(', ');
updateHistory(`Opa! O dia ${dateInput} (${ptWeekday}) não é um dos dias de atendimento do(a) Dr(a). ${data.selectedDoctor.full_name}. \n\nPor favor, escolha uma data que caia em: **${ptWeekdaysForDisplay}**.`);
return;
}
updateHistory(`Ok, verificando horários livres para ${ptWeekday} (${dateInput})...`);
// Cria datas no mesmo formato que o AgendaForm (sem conversão de fuso)
const startDate = `${isoDateStr}T00:00:00.000Z`;
const endDate = `${isoDateStr}T23:59:59.999Z`;
const payload = {
doctor_id: data.selectedDoctor.id,
start_date: startDate,
end_date: endDate,
appointment_type: "presencial"
};
console.log("🚀 Chatbox - Payload enviado para get-available-slots:", payload);
console.log("🔑 Chatbox - Token do usuário:", token ? "EXISTS" : "NULL");
console.log("👤 Chatbox - Patient ID:", patient_id);
console.log("🆔 Chatbox - Doctor ID sendo consultado:", data.selectedDoctor?.id || "Ainda não selecionado");
try {
// USA A MESMA EDGE FUNCTION que o AgendaForm para garantir consistência
const response = await fetch(
`${supabaseUrl}/functions/v1/get-available-slots`,
{
method: "POST",
headers,
body: JSON.stringify(payload),
}
);
const data_slots = await response.json();
console.log("🔍 Chatbox (Paciente) - Resposta da Edge Function:", data_slots);
if (!response.ok) throw new Error(data_slots.error || "Erro ao buscar horários");
// Usa exatamente a mesma lógica do AgendaForm
const slotsDisponiveis = (data_slots?.slots || []).filter((s) => s.available);
console.log("✅ Chatbox (Paciente) - Slots disponíveis após filtro:", slotsDisponiveis);
console.log("🔍 Chatbox (Paciente) - Todos os slots (antes do filtro):", data_slots?.slots);
console.log("❌ Chatbox (Paciente) - Slots NÃO disponíveis:", (data_slots?.slots || []).filter((s) => !s.available));
if (slotsDisponiveis.length === 0) {
updateHistory(`Desculpe, não há horários livres para ${data.selectedDoctor.full_name} no dia ${dateInput}. Por favor, escolha outra data.`);
return;
}
// Converte para o formato que o chatbox espera
const availableSlots = slotsDisponiveis.map((slot) => ({
displayTime: slot.datetime.split("T")[1].substring(0, 5), // "HH:MM"
originalDateTime: slot.datetime
}));
// Mostra os horários disponíveis para o usuário
const slotsListText = availableSlots.map(slot => `- ${slot.displayTime}`).join('\n');
const exampleTime = availableSlots[0].displayTime;
updateHistory(`Perfeito! Horários disponíveis para ${dateInput} (${ptWeekday}):\n${slotsListText}\n\nQual horário você prefere? (Ex: ${exampleTime})`);
setConversationState({
...conversationState,
step: 'awaiting_time',
data: {
...data,
selectedDateISO: isoDateStr,
availableSlotsInfo: availableSlots,
slotDuration: 30
}
});
} catch (err) {
console.error("Erro detalhado ao processar horários:", err);
updateHistory(`Erro ao consultar horários: ${err.message}. Tente outra data.`);
}
break;
// ETAPA 4: Usuário digitou o horário
case 'awaiting_time':
let timeInput = message.trim(); // Ex: "16:00"
// Garante que o formato seja "HH:MM"
if (timeInput.match(/^\d{2}:\d{2}:\d{2}$/)) {
timeInput = timeInput.substring(0, 5);
}
// Acha o objeto correspondente na lista availableSlotsInfo
const chosenSlotInfo = data.availableSlotsInfo.find(info => info.displayTime === timeInput);
if (!chosenSlotInfo) {
const exampleTime = data.availableSlotsInfo[0]?.displayTime || "09:00";
updateHistory(`Não encontrei esse horário. Por favor, digite exatamente como aparece na lista (Ex: ${exampleTime}).`);
return;
}
// Usa o horário direto do banco sem conversão
const scheduled_at_iso = chosenSlotInfo.originalDateTime;
const duration_minutes = data.slotDuration;
updateHistory(`Confirmando agendamento para ${data.selectedDateISO} às ${timeInput}...`);
console.log("Horário selecionado:", timeInput);
console.log("DateTime que será salvo:", scheduled_at_iso);
const appointmentBody = {
doctor_id: data.selectedDoctor.id,
patient_id: user.id,
scheduled_at: scheduled_at_iso,
duration_minutes: duration_minutes,
created_by: user.id
};
try {
const res = await fetch(`${supabaseUrl}/rest/v1/appointments`, {
method: 'POST',
headers: {
...headers,
"Prefer": "return=minimal"
},
body: JSON.stringify(appointmentBody)
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || "Não foi possível confirmar o agendamento.");
}
updateHistory("Consulta marcada com sucesso! ✅");
resetConversation();
} catch (err) {
updateHistory(`Erro ao marcar consulta: ${err.message}.`);
resetConversation();
}
break;
default:
updateHistory("Desculpe, me perdi. Vamos começar de novo.");
resetConversation();
}
};
// Função para extrair recomendação de consulta do Gemini
const extractConsultationRecommendation = (text) => {
const match = text.match(/RECOMENDO_CONSULTA:\s*([^\n]+)/i);
if (match) {
return match[1].trim().toLowerCase();
}
return null;
};
// Função para iniciar agendamento automático com especialidade (após confirmação)
const startAutomaticBooking = (specialty, updateHistory) => {
// Busca médicos da especialidade
const filteredDoctors = allDoctors.filter(doc =>
doc.specialty && doc.specialty.toLowerCase() === specialty
);
if (filteredDoctors.length === 0) {
updateHistory(`\n💡 Infelizmente não encontrei especialistas em ${specialty} disponíveis no momento. Entre em contato conosco para mais informações.`);
resetConversation();
return;
}
const doctorListText = filteredDoctors.map((doc, i) => `${i + 1}. ${doc.full_name}`).join('\n');
updateHistory(`\nÓtimo! Encontrei estes especialistas em ${specialty}:\n${doctorListText}\n\nQual você prefere? (Digite o nome ou o número)`);
setConversationState({
flow: 'booking',
step: 'awaiting_doctor_choice',
data: { specialty, doctorsList: filteredDoctors }
});
};
// generateBotResponse HÍBRIDO (atualizado para dar dicas médicas)
const generateBotResponse = async (history) => {
const updateHistory = (text, isError = false) => {
setChatHistory(prev => [...prev.filter(msg => msg.text !== "Pensando..."), { role: "model", text, isError }]);
};
const lastUserMessage = history[history.length - 1].text.toLowerCase();
let intentFound = false;
// Checa o fluxo de confirmação de agendamento PRIMEIRO
if (conversationState.flow === 'booking_confirmation') {
await handleBookingConfirmation(lastUserMessage, updateHistory);
return;
}
// Checa o fluxo de agendamento
if (conversationState.flow === 'booking') {
await handleBookingFlow(lastUserMessage, updateHistory);
return; // Interrompe para não buscar intents
}
// --- Lógica de intents (a mesma de antes) ---
for (const intent of intents) {
// 1. Checa intents com condição especial (como "salvarPaciente")
if (intent.condition && intent.condition(lastUserMessage)) {
await intent.action(lastUserMessage, updateHistory);
intentFound = true;
break;
}
// 2. Checa intents que usam KEYWORDS + ENTITIES (como "listarInformacoes")
if (intent.keywords && intent.entities) {
const actionKeyword = intent.keywords.find(kw => lastUserMessage.includes(kw.toLowerCase()));
if (actionKeyword) {
let matchedEntity = null;
for (const entity of intent.entities) {
const entityValue = entity.values.find(val => lastUserMessage.includes(val.toLowerCase()));
if (entityValue) {
matchedEntity = entity;
break;
}
}
if (matchedEntity) {
await intent.action(lastUserMessage, updateHistory, matchedEntity);
intentFound = true;
break;
}
}
}
// 3. Checa intents com KEYWORDS simples, sem entidades (como "iniciarAgendamento")
if (intent.keywords && !intent.entities && !intent.condition) {
if (intent.keywords.some((kw) => lastUserMessage.includes(kw.toLowerCase()))) {
// CORRIGIDO AQUI: (era lastMessage)
await intent.action(lastUserMessage, updateHistory);
intentFound = true;
break;
}
}
}
if (intentFound) return;
// 4. Fallback para API externa (Gemini)
history = history.map(({ role, text }) => ({ role, parts: [{ text }] }));
const requestOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: history })
};
try {
const response = await fetch(import.meta.env.VITE_API_URL, requestOptions);
const data = await response.json();
if (!response.ok) throw new Error(data.error.message || "Algo deu errado");
const apiResponseText = data.candidates[0].content.parts[0].text.replace(/\*\*(.*?)\*\*/g, "$1").trim();
// Verifica se o Gemini recomendou uma consulta médica
const recommendedSpecialty = extractConsultationRecommendation(apiResponseText);
if (recommendedSpecialty) {
// Remove a linha "RECOMENDO_CONSULTA" da resposta visível
const cleanedResponse = apiResponseText.replace(/RECOMENDO_CONSULTA:\s*[^\n]+/i, '').trim();
updateHistory(cleanedResponse);
// Coloca o sistema em modo de espera de confirmação diretamente
setConversationState({
flow: 'booking_confirmation',
step: 'awaiting_confirmation',
data: { specialty: recommendedSpecialty }
});
} else {
// Resposta normal sem recomendação de consulta
updateHistory(apiResponseText);
}
} catch (error) {
updateHistory(error.message, true);
}
};
// Scroll (sem mudanças)
useEffect(() => {
if (chatBodyRef.current) {
chatBodyRef.current.scrollTo({ top: chatBodyRef.current.scrollHeight, behavior: "smooth" })
}
}, [chatHistory])
// --- JSX (sem mudanças) ---
return (
<div className={`container-chatbox ${showChatbot ? "show-chatbot" : ""}`}>
{/* Este é o botão de ícone no canto */}
<button onClick={() => setShowChatbot(prev => !prev)} id="chatbot-toggler">
<span className="material-symbols-rounded">mode_comment</span>
<span className="material-symbols-rounded">close</span>
</button>
{/* Este é o popup do chat */}
<div className="chatbot-popup">
<div className="chat-header">
<div className="header-info">
<ChatbotIcon />
<h2 className="logo-text">MediChat</h2>
</div>
<button onClick={() => setShowChatbot(prev => !prev)}
className="material-symbols-rounded">keyboard_arrow_down</button>
</div>
{/* O corpo do chat, onde as mensagens aparecem */}
<div ref={chatBodyRef} className="chat-body">
{/* A mensagem de boas-vindas inicial */}
<div className="message bot-message">
<ChatbotIcon />
<p className="message-text">
Bem-vindo 👋 <br /> Sou a assistente virtual da MediConnect, como posso te ajudar?
</p>
</div>
{/* O histórico de chat */}
{chatHistory.map((chat, index) => (
<ChatMessage key={index} chat={chat} />
))}
</div> {/* Fim do chat-body */}
{/* O rodapé com o input de texto */}
<div className="chat-footer">
<ChatForm
chatHistory={chatHistory}
setChatHistory={setChatHistory}
generateBotResponse={generateBotResponse}
/>
</div>
</div> {/* Fim do chatbot-popup */}
</div> /* Fim do container-chatbox */
)
}
export default Chatbox;

View File

View File

@ -0,0 +1,172 @@
/* Estilo para chat com contatos à esquerda e mensagens à direita */
.chatpanel-container {
display: flex;
width: 80vw;
height: 80vh;
background: #f7f7f7;
border-radius: 0;
box-shadow: none;
overflow: hidden;
border-radius: 10px;
border-color: linear-gradient(135deg, #004a99, #0077cc);
}
.chatpanel-contacts {
width: 260px;
background: #fff;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
padding: 12px 0;
box-shadow: 2px 0 8px rgba(0,0,0,0.03);
height: 100%;
}
.contacts-list {
flex: 1;
overflow-y: auto;
max-height: 70vh;
padding-bottom: 10px;
}
.contacts-search {
margin: 0 12px 10px;
padding: 8px 12px;
border-radius: 20px;
border: 1px solid #ccc;
font-size: 1rem;
outline: none;
}
.chatpanel-contact {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 22px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
font-size: 1.08rem;
border-radius: 8px;
transition: background 0.2s, box-shadow 0.2s;
}
.chatpanel-contact:hover {
background: #e6f3ff;
box-shadow: 0 2px 8px rgba(0,123,255,0.08);
}
.chatpanel-contact.selected {
background: linear-gradient(135deg, #004a99, #0077cc);
color: #fff;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,123,255,0.12);
}
.chatpanel-contact-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: #e6f3ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: bold;
color: #004a99;
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
}
.chatpanel-chat {
flex: 1;
display: flex;
flex-direction: column;
background: #f0f0f0;
}
.chatpanel-header {
background: linear-gradient(135deg, #004a99, #0077cc);
color: #fff;
padding: 16px;
font-size: 1.2rem;
border-top-right-radius: 16px;
display: flex;
align-items: center;
gap: 18px;
}
.search {
margin-bottom: 0;
padding: 8px 12px;
margin-left: auto;
border-radius: 20px;
border: 1px solid #ccc;
font-size: 1rem;
outline: none;
background: #fff;
color: #004a99;
}
.chatpanel-messages {
flex: 1;
padding: 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.chatpanel-bubble {
max-width: 65%;
padding: 10px 16px;
border-radius: 18px;
font-size: 1rem;
word-break: break-word;
margin-bottom: 2px;
display: inline-block;
}
.chatpanel-bubble.me {
background: linear-gradient(135deg, #004a99, #0077cc);
color: white;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.chatpanel-bubble.other {
background: #fff;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.chatpanel-input-area {
display: flex;
padding: 14px;
background: #eee;
border-bottom-right-radius: 16px;
}
.chatpanel-input {
flex: 1;
padding: 8px 12px;
border-radius: 20px;
border: 1px solid #ccc;
font-size: 1rem;
outline: none;
}
.chatpanel-send-btn {
background: linear-gradient(135deg, #004a99, #0077cc);
color: #fff;
border: none;
border-radius: 20px;
padding: 0 18px;
margin-left: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.chatpanel-send-btn:hover {
background:linear-gradient(135deg, #004a99, #0077cc);
}

View File

@ -0,0 +1,469 @@
import { useState, useEffect } from "react";
import { withMask } from "use-mask-input";
import { Link, useParams, useNavigate } from "react-router-dom";
import "../../assets/css/index.css";
import { getAccessToken } from "../../utils/auth.js";
import Swal from "sweetalert2";
import { getUserRole } from "../../utils/userInfo.js";
function ConsultaEdit() {
const tokenUsuario = getAccessToken()
const { id } = useParams();
const navigate = useNavigate();
const role = getUserRole();
const [minDate, setMinDate] = useState("");
const [pacientes, setPacientes] = useState([]);
const [medicos, setMedicos] = useState([]);
const [horariosDisponiveis, setHorariosDisponiveis] = useState([]);
const [carregandoHorarios, setCarregandoHorarios] = useState(false);
const [apiResponse, setApiResponse] = useState(null);
// Dados do formulário
const [formData, setFormData] = useState({
appointment_type: "presencial",
chief_complaint: "",
doctor_id: "",
duration_minutes: 30,
insurance_provider: "",
patient_id: "",
patient_notes: "",
scheduled_date: "",
scheduled_time: "",
});
// Define a data mínima
useEffect(() => {
const today = new Date();
const offset = today.getTimezoneOffset();
today.setMinutes(today.getMinutes() - offset);
setMinDate(today.toISOString().split("T")[0]);
}, []);
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Busca consulta existente
useEffect(() => {
const fetchConsulta = async () => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/appointments?id=eq.${id}`,
{
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await res.json();
if (data.length > 0) {
const consulta = data[0];
const date = consulta.scheduled_at
? consulta.scheduled_at.split("T")[0]
: "";
const time = consulta.scheduled_at
? consulta.scheduled_at.split("T")[1].substring(0, 5)
: "";
setFormData({
appointment_type: consulta.appointment_type || "presencial",
chief_complaint: consulta.chief_complaint || "",
doctor_id: consulta.doctor_id || "",
duration_minutes: consulta.duration_minutes || 30,
insurance_provider: consulta.insurance_provider || "",
patient_id: consulta.patient_id || "",
patient_notes: consulta.patient_notes || "",
scheduled_date: date,
scheduled_time: time,
});
}
} catch (err) {
console.error(err);
Swal.fire("Erro", "Falha ao carregar os dados da consulta.", "error");
}
};
fetchConsulta();
}, [id]);
// Busca pacientes
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/patients`, {
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
})
.then((r) => r.json())
.then(setPacientes)
.catch((err) => console.error(err));
}, []);
// Busca médicos
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/doctors`, {
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
})
.then((r) => r.json())
.then(setMedicos)
.catch((err) => console.error(err));
}, []);
// 🔹 Buscar horários disponíveis
const fetchHorariosDisponiveis = async (doctorId, date, appointmentType) => {
if (!doctorId || !date) {
setHorariosDisponiveis([]);
setApiResponse(null);
return;
}
setCarregandoHorarios(true);
const startDate = `${date}T00:00:00.000Z`;
const endDate = `${date}T23:59:59.999Z`;
const payload = {
doctor_id: doctorId,
start_date: startDate,
end_date: endDate,
appointment_type: appointmentType || "presencial",
};
console.log("Payload get-available-slots:", payload);
try {
const response = await fetch(
`${supabaseUrl}/functions/v1/get-available-slots`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
body: JSON.stringify(payload),
}
);
const data = await response.json();
setApiResponse(data);
if (!response.ok) throw new Error(data.error || "Erro ao buscar horários");
const slotsDisponiveis = (data?.slots || []).filter((s) => s.available);
setHorariosDisponiveis(slotsDisponiveis);
if (slotsDisponiveis.length === 0)
Swal.fire("Atenção", "Nenhum horário disponível para este dia.", "info");
} catch (error) {
console.error("Erro ao buscar horários disponíveis:", error);
setHorariosDisponiveis([]);
setApiResponse(null);
Swal.fire("Erro", "Não foi possível obter os horários disponíveis.", "error");
} finally {
setCarregandoHorarios(false);
}
};
// Atualiza horários sempre que o médico, data ou tipo de consulta mudam
useEffect(() => {
if (formData.doctor_id && formData.scheduled_date) {
console.log("Buscando horários para:", {
doctor_id: formData.doctor_id,
scheduled_date: formData.scheduled_date,
appointment_type: formData.appointment_type
});
fetchHorariosDisponiveis(formData.doctor_id, formData.scheduled_date, formData.appointment_type);
} else {
setHorariosDisponiveis([]);
}
}, [formData.doctor_id, formData.scheduled_date, formData.appointment_type]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Atualiza consulta
const handleEdit = async (e) => {
e.preventDefault();
if (!formData.scheduled_date || !formData.scheduled_time) {
Swal.fire("Atenção", "Selecione uma data e horário válidos", "warning");
return;
}
const result = await Swal.fire({
title: "Deseja salvar as alterações?",
text: "As modificações serão salvas permanentemente.",
icon: "question",
showCancelButton: true,
confirmButtonText: "Salvar",
cancelButtonText: "Cancelar",
});
if (!result.isConfirmed) return;
const scheduled_at = `${formData.scheduled_date}T${formData.scheduled_time}:00Z`;
const updatedData = {
appointment_type: formData.appointment_type,
chief_complaint: formData.chief_complaint,
doctor_id: formData.doctor_id,
duration_minutes: formData.duration_minutes,
insurance_provider: formData.insurance_provider,
patient_id: formData.patient_id,
patient_notes: formData.patient_notes,
scheduled_at,
};
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/appointments?id=eq.${id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
Prefer: "return=representation",
},
body: JSON.stringify(updatedData),
}
);
if (res.ok) {
Swal.fire("Sucesso!", "Consulta atualizada com sucesso!", "success").then(() =>
navigate(`/${role}/consultalist`)
);
} else {
const error = await res.json();
console.error(error);
Swal.fire("Erro", "Não foi possível atualizar a consulta.", "error");
}
} catch (err) {
console.error(err);
Swal.fire("Erro", "Falha de conexão com o servidor.", "error");
}
};
return (
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h1>Editar consulta</h1>
<hr />
<h3>Informações do paciente</h3>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleEdit}>
<div className="row">
{/* Paciente */}
<div className="col-md-6">
<div className="form-group">
<label>
Nome do paciente<span className="text-danger">*</span>
</label>
<select
className="select form-control"
name="patient_id"
value={formData.patient_id}
onChange={handleChange}
required
>
<option value="">Selecione o paciente</option>
{pacientes.map((p) => {
const nomePaciente =
p.name ||
p.nome ||
p.full_name ||
p.paciente_nome ||
`Paciente #${p.id}`;
return (
<option key={p.id} value={p.id}>
{nomePaciente}
</option>
);
})}
</select>
</div>
</div>
{/* Tipo */}
<div className="col-md-6">
<div className="form-group">
<label>Tipo da consulta</label>
<select
className="select form-control"
name="appointment_type"
value={formData.appointment_type}
onChange={handleChange}
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
</div>
</div>
<hr />
<h3>Informações do atendimento</h3>
{/* Médico */}
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Médico<span className="text-danger">*</span></label>
<select
className="select form-control"
name="doctor_id"
value={formData.doctor_id}
onChange={handleChange}
required
>
<option value="">Selecione o médico</option>
{medicos.map((m) => {
const nomeMedico =
m.name ||
m.nome ||
m.full_name ||
m.doctor_name ||
`Médico #${m.id}`;
return (
<option key={m.id} value={m.id}>
{nomeMedico}
</option>
);
})}
</select>
</div>
</div>
{/* Convênio */}
<div className="col-md-6">
<div className="form-group">
<label>Convênio</label>
<input
type="text"
className="form-control"
name="insurance_provider"
value={formData.insurance_provider || ""}
onChange={handleChange}
/>
</div>
</div>
</div>
{/* Motivo */}
<div className="form-group">
<label>Motivo / Queixa principal</label>
<input
type="text"
className="form-control"
name="chief_complaint"
value={formData.chief_complaint || ""}
onChange={handleChange}
/>
</div>
{/* Data e horário */}
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Data da consulta<span className="text-danger">*</span></label>
<input
type="date"
className="form-control"
min={minDate}
name="scheduled_date"
value={formData.scheduled_date || ""}
onChange={handleChange}
required
/>
</div>
</div>
{/* Horário */}
<div className="col-md-6">
<div className="form-group">
<label>Horário<span className="text-danger">*</span></label>
{carregandoHorarios ? (
<select className="select form-control" disabled>
<option>Carregando horários...</option>
</select>
) : (
<select
className="select form-control"
name="scheduled_time"
value={formData.scheduled_time || ""}
onChange={handleChange}
required
disabled={!horariosDisponiveis.length && formData.doctor_id && formData.scheduled_date}
>
<option value="">
{!formData.doctor_id || !formData.scheduled_date
? "Selecione médico e data primeiro"
: horariosDisponiveis.length
? "Selecione um horário"
: "Nenhum horário disponível"}
</option>
{horariosDisponiveis.map((slot) => {
const time = slot.datetime.split("T")[1].substring(0, 5);
return (
<option key={slot.datetime} value={time}>
{time}
</option>
);
})}
</select>
)}
</div>
</div>
</div>
{/* Notas */}
<div className="form-group">
<label>Anotações do paciente</label>
<textarea
cols="30"
rows="4"
className="form-control"
name="patient_notes"
value={formData.patient_notes || ""}
onChange={handleChange}
></textarea>
</div>
<div className="m-t-20 text-center">
<Link to={`/${role}/consultalist`} className="btn btn-secondary mr-3">
<i className="fa fa-arrow-left"></i> Voltar
</Link>
<button
className="btn btn-primary submit-btn"
type="submit"
disabled={!formData.doctor_id || !formData.patient_id || !formData.scheduled_date || !formData.scheduled_time}
>
<i className="fa fa-save"></i> Salvar alterações
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}
export default ConsultaEdit;

View File

@ -0,0 +1,446 @@
import "../../assets/css/index.css";
import { withMask } from "use-mask-input";
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
import { useResponsive } from '../../utils/useResponsive.js';
import { getUserRole } from "../../utils/userInfo.js";
function EditDoctor() {
const [doctorData, setDoctorData] = useState({});
const { id } = useParams();
const navigate = useNavigate();
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Buscar médico pelo ID
useEffect(() => {
const fetchDoctor = async () => {
try {
const tokenUsuario = getAccessToken();
const response = await fetch(
`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`,
{
method: "GET",
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await response.json();
if (data && data.length > 0) {
setDoctorData(data[0]);
}
} catch (err) {
console.error("Erro ao buscar médico:", err);
}
};
fetchDoctor();
}, [id]);
// Atualizar campos
const handleChange = (e) => {
const { name, value } = e.target;
setDoctorData((prev) => ({
...prev,
[name]: value,
}));
};
// Buscar CEP
const buscarCep = () => {
const cep = doctorData.cep?.replace(/\D/g, "");
if (!cep) return;
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then((response) => response.json())
.then((data) => {
setDoctorData((prev) => ({
...prev,
city: data.localidade || "",
state: data.uf || "",
street: data.logradouro || "",
neighborhood: data.bairro || "",
}));
})
.catch((err) => console.error("Erro ao buscar CEP:", err));
};
// Salvar alterações
const handleEdit = async (e) => {
e.preventDefault();
const result = await Swal.fire({
title: "Deseja salvar as alterações?",
text: "As modificações serão salvas permanentemente.",
icon: "question",
showDenyButton: true,
showCancelButton: true,
confirmButtonText: "Salvar",
denyButtonText: `Não salvar`,
cancelButtonText: "Cancelar",
});
if (result.isConfirmed) {
try {
const tokenUsuario = getAccessToken();
const response = await fetch(
`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`,
{
method: "PATCH",
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
"Content-Type": "application/json",
},
body: JSON.stringify(doctorData),
}
);
if (!response.ok) {
const err = await response.json();
Swal.fire("Erro!", err.message || "Não foi possível salvar.", "error");
return;
}
await Swal.fire("Sucesso!", "As alterações foram salvas.", "success");
navigate(`/${role}/doctorlist`);
} catch (err) {
console.error("Erro inesperado:", err);
Swal.fire("Erro!", "Não foi possível salvar as alterações.", "error");
}
}
};
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h4 className="page-title">Editar Médico</h4>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleEdit}>
<div className="row">
{/* Nome completo */}
<div className="col-sm-6">
<div className="form-group">
<label>Nome Completo</label>
<input
className="form-control"
type="text"
name="full_name"
value={doctorData.full_name || ""}
onChange={handleChange}
/>
</div>
</div>
{/* CPF */}
<div className="col-sm-6">
<div className="form-group">
<label>CPF</label>
<input
className="form-control"
type="text"
ref={withMask("cpf")}
name="cpf"
value={doctorData.cpf || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Email */}
<div className="col-sm-6">
<div className="form-group">
<label>Email</label>
<input
className="form-control"
type="email"
name="email"
value={doctorData.email || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Telefone */}
<div className="col-sm-6">
<div className="form-group">
<label>Telefone Celular</label>
<input
className="form-control"
type="text"
ref={withMask("+99 (99) 99999-9999")}
name="phone_mobile"
value={doctorData.phone_mobile || ""}
onChange={handleChange}
/>
</div>
</div>
{/* CRM */}
<div className="col-sm-3">
<div className="form-group">
<label>CRM</label>
<input
className="form-control"
type="text"
name="crm"
value={doctorData.crm || ""}
onChange={handleChange}
/>
</div>
</div>
{/* CRM UF */}
<div className="col-sm-3">
<div className="form-group">
<label>CRM - UF</label>
<select
className="form-control"
name="crm_uf"
value={doctorData.crm_uf || ""}
onChange={handleChange}
>
<option value="">Selecione</option>
<option value="AC">AC - Acre</option>
<option value="AL">AL - Alagoas</option>
<option value="AP">AP - Amapá</option>
<option value="AM">AM - Amazonas</option>
<option value="BA">BA - Bahia</option>
<option value="CE">CE - Ceará</option>
<option value="DF">DF - Distrito Federal</option>
<option value="ES">ES - Espírito Santo</option>
<option value="GO">GO - Goiás</option>
<option value="MA">MA - Maranhão</option>
<option value="MT">MT - Mato Grosso</option>
<option value="MS">MS - Mato Grosso do Sul</option>
<option value="MG">MG - Minas Gerais</option>
<option value="PA">PA - Pará</option>
<option value="PB">PB - Paraíba</option>
<option value="PR">PR - Paraná</option>
<option value="PE">PE - Pernambuco</option>
<option value="PI">PI - Piauí</option>
<option value="RJ">RJ - Rio de Janeiro</option>
<option value="RN">RN - Rio Grande do Norte</option>
<option value="RS">RS - Rio Grande do Sul</option>
<option value="RO">RO - Rondônia</option>
<option value="RR">RR - Roraima</option>
<option value="SC">SC - Santa Catarina</option>
<option value="SP">SP - São Paulo</option>
<option value="SE">SE - Sergipe</option>
<option value="TO">TO - Tocantins</option>
</select>
</div>
</div>
{/* Especialidade */}
<div className="col-sm-6">
<div className="form-group">
<label>Especialidade</label>
<select
className="form-control"
name="specialty"
value={doctorData.specialty || ""}
onChange={handleChange}
>
<option value="">Selecione</option>
<option value="Cardiologia">Cardiologia</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Endocrinologia">Endocrinologia</option>
<option value="Gastroenterologia">Gastroenterologia</option>
<option value="Ginecologia">Ginecologia</option>
<option value="Neurologia">Neurologia</option>
<option value="Oftalmologia">Oftalmologia</option>
<option value="Ortopedia">Ortopedia</option>
<option value="Otorrinolaringologia">Otorrinolaringologia</option>
<option value="Pediatria">Pediatria</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Urologia">Urologia</option>
</select>
</div>
</div>
{/* Data de nascimento */}
<div className="col-sm-6">
<div className="form-group">
<label>Data de Nascimento</label>
<input
type="date"
className="form-control"
name="birth_date"
value={doctorData.birth_date || ""}
onChange={handleChange}
/>
</div>
</div>
{/* CEP */}
<div className="col-sm-4">
<div className="form-group">
<label>CEP</label>
<input
type="text"
className="form-control"
name="cep"
value={doctorData.cep || ""}
onChange={handleChange}
onBlur={buscarCep}
/>
</div>
</div>
{/* Rua */}
<div className="col-sm-8">
<div className="form-group">
<label>Logradouro</label>
<input
type="text"
className="form-control"
name="street"
value={doctorData.street || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Número */}
<div className="col-sm-4">
<div className="form-group">
<label>Número</label>
<input
type="text"
className="form-control"
name="number"
value={doctorData.number || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Complemento */}
<div className="col-sm-4">
<div className="form-group">
<label>Complemento</label>
<input
type="text"
className="form-control"
name="complement"
value={doctorData.complement || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Bairro */}
<div className="col-sm-4">
<div className="form-group">
<label>Bairro</label>
<input
type="text"
className="form-control"
name="neighborhood"
value={doctorData.neighborhood || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Cidade */}
<div className="col-sm-6">
<div className="form-group">
<label>Cidade</label>
<input
type="text"
className="form-control"
name="city"
value={doctorData.city || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Estado */}
<div className="col-sm-6">
<div className="form-group">
<label>Estado</label>
<input
type="text"
className="form-control"
name="state"
value={doctorData.state || ""}
onChange={handleChange}
/>
</div>
</div>
{/* Ativo/Inativo */}
<div className="col-sm-12">
<div className="form-group">
<label className="d-block">Status</label>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="active"
id="ativo"
value="true"
checked={doctorData.active === true}
onChange={() =>
setDoctorData((prev) => ({ ...prev, active: true }))
}
/>
<label className="form-check-label" htmlFor="ativo">
Ativo
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="active"
id="inativo"
value="false"
checked={doctorData.active === false}
onChange={() =>
setDoctorData((prev) => ({ ...prev, active: false }))
}
/>
<label className="form-check-label" htmlFor="inativo">
Inativo
</label>
</div>
</div>
</div>
</div>
<div className="m-t-20 text-center">
<button className="btn btn-primary submit-btn" type="submit">
Salvar Alterações
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default EditDoctor;

View File

@ -0,0 +1,478 @@
// PatientList.jsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image';
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { getAccessToken } from '../../utils/auth';
import { useParams } from 'react-router-dom';
import { Card, Collapse } from "react-bootstrap"; // <-- IMPORT CORRETO
import { ChevronDown, ChevronUp } from "lucide-react";
import Select from 'react-select';
import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom';
import { useRef } from 'react';
import { InterimMark } from '../../utils/InterimMark';
import { FaMicrophone } from "react-icons/fa";
import { getUserRole } from '../../utils/userInfo';
function Bar({ comandos, handleSubmit, toggleRecording, isRecording }) {
const inputRef = useRef(null);
const handleAbrirExplorador = () => {
inputRef.current.click(); // abre o explorador
};
const handleArquivoSelecionado = (event) => {
const arquivo = event.target.files[0];
if (arquivo) {
const imageUrl = URL.createObjectURL(arquivo);
comandos.agregarImagen(imageUrl);
event.target.value = null;
}
};
return (
<>
<div className="toolbar">
<div className="left">
<button onClick={comandos.toggleBold} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 11H12.5C13.8807 11 15 9.88071 15 8.5C15 7.11929 13.8807 6 12.5 6H8V11ZM18 15.5C18 17.9853 15.9853 20 13.5 20H6V4H12.5C14.9853 4 17 6.01472 17 8.5C17 9.70431 16.5269 10.7981 15.7564 11.6058C17.0979 12.3847 18 13.837 18 15.5ZM8 13V18H13.5C14.8807 18 16 16.8807 16 15.5C16 14.1193 14.8807 13 13.5 13H8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleItalic} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 20H7V18H9.92661L12.0425 6H9V4H17V6H14.0734L11.9575 18H15V20Z"></path>
</svg>
</button>
<button onClick={comandos.toggleUnderline} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z"></path>
</svg>
</button>
{/*<button onClick={comandos.toggleCodeBlock} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
</button> */}
<button onClick={comandos.toggleH1} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 20H11V13H4V20H2V4H4V11H11V4H13V20ZM21.0005 8V20H19.0005L19 10.204L17 10.74V8.67L19.5005 8H21.0005Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH2} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4V11H11V4H13V20H11V13H4V20H2V4H4ZM18.5 8C20.5711 8 22.25 9.67893 22.25 11.75C22.25 12.6074 21.9623 13.3976 21.4781 14.0292L21.3302 14.2102L18.0343 18H22V20H15L14.9993 18.444L19.8207 12.8981C20.0881 12.5908 20.25 12.1893 20.25 11.75C20.25 10.7835 19.4665 10 18.5 10C17.5818 10 16.8288 10.7071 16.7558 11.6065L16.75 11.75H14.75C14.75 9.67893 16.4289 8 18.5 8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH3} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 8L21.9984 10L19.4934 12.883C21.0823 13.3184 22.25 14.7728 22.25 16.5C22.25 18.5711 20.5711 20.25 18.5 20.25C16.674 20.25 15.1528 18.9449 14.8184 17.2166L16.7821 16.8352C16.9384 17.6413 17.6481 18.25 18.5 18.25C19.4665 18.25 20.25 17.4665 20.25 16.5C20.25 15.5335 19.4665 14.75 18.5 14.75C18.214 14.75 17.944 14.8186 17.7056 14.9403L16.3992 13.3932L19.3484 10H15V8H22ZM4 4V11H11V4H13V20H11V13H4V20H2V4H4Z"></path>
</svg>
</button>
<button onClick={comandos.toggleParrafo} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6V21H10V16C6.68629 16 4 13.3137 4 10C4 6.68629 6.68629 4 10 4H20V6H17V21H15V6H12ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14V6Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaOrdenada} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM5 3V6H6V7H3V6H4V4H3V3H5ZM3 14V11.5H5V11H3V10H6V12.5H4V13H6V14H3ZM5 19.5H3V18.5H5V18H3V17H6V21H3V20H5V19.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaPuntos} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<>
<input
type="file"
accept="image/*"
ref={inputRef}
onChange={handleArquivoSelecionado}
style={{ display: "none" }} // esconde o input
/>
<button onClick={handleAbrirExplorador}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M2.9918 21C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918ZM20 15V5H4V19L14 9L20 15ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path>
</svg>
</button>
</>
<button
onClick={toggleRecording}
className={`toolbar-button ${isRecording ? "active" : ""}`}
title={isRecording ? "Parar ditado" : "Iniciar ditado por voz"}
>
<FaMicrophone size={18} />
</button>
{/*<button onClick={comandos.agregarLink} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
</button> */}
</div>
<div className="right">
<button onClick={handleSubmit} className="btnGuardar">
<span>Editar Laudo</span>
</button>
</div>
</div>
</>
);
};
function LaudoAdmEdit() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [Laudos, setLaudos] = useState({})
const tokenUsuario = getAccessToken()
const { id } = useParams()
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const role = getUserRole();
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/reports?id=eq.${id}`, requestOptions)
.then(response => response.json())
.then(result => {
// result é um array, pegue o primeiro laudo
const laudo = Array.isArray(result) ? result[0] : null;
setLaudos(laudo || {});
})
.catch(error => console.log('error', error));
}, [id]);
const editor = useEditor({
extensions: [StarterKit, Image, InterimMark],
content: Laudos?.content_html || "<p>Escreva o laudo aqui...</p>",
onUpdate: ({ editor }) => {
setLaudos({
...Laudos,
content_html: editor.getHTML()
});
}
});
const [isRecording, setIsRecording] = useState(false);
const recognitionRef = useRef(null);
const lastInsertedRef = useRef({ from: -1, to: -1, text: '' });
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Seu navegador não suporta reconhecimento de voz 😢");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "pt-BR";
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event) => {
if (!editor) return;
const result = event.results[0];
const transcript = result[0].transcript;
const last = lastInsertedRef.current;
// --- CORREÇÃO DE LÓGICA ---
// Vamos rodar a deleção como um comando SEPARADO primeiro.
if (last.from !== -1 &&
editor.state.doc.textBetween(last.from, last.to) === last.text)
{
// Roda a deleção e PARA.
editor.chain().focus()
.deleteRange({ from: last.from, to: last.to })
.run();
}
// Pega a posição ATUAL (depois da deleção)
const currentPos = editor.state.selection.from;
if (result.isFinal) {
// --- RESULTADO FINAL (PRETO) ---
// Roda a inserção final como um comando SEPARADO.
editor.chain().focus()
.insertContent(transcript + ' ')
.run();
// Reseta a Ref
lastInsertedRef.current = { from: -1, to: -1, text: '' };
} else {
// --- RESULTADO PROVISÓRIO (CINZA) ---
// Esta é a nova estratégia: "Ligar" a mark, inserir, "Desligar" a mark.
// Roda tudo como um comando SEPARADO.
editor.chain()
.focus()
.setMark('interimMark') // <-- "Pincel cinza" LIGADO
.insertContent(transcript) // <-- Insere o texto
.unsetMark('interimMark') // <-- "Pincel cinza" DESLIGADO
.run();
// Atualiza a Ref com a posição do texto cinza
lastInsertedRef.current = {
from: currentPos,
to: currentPos + transcript.length,
text: transcript
};
}
// Não precisamos mais do 'editorChain.run()' aqui embaixo
};
recognition.onerror = (err) => {
// ... (código do onerror sem mudanças)
};
recognition.onend = () => {
// ... (código do onend sem mudanças)
};
recognitionRef.current = recognition;
return () => {
recognition.stop();
};
}, [editor, isRecording]);
const toggleRecording = () => {
if (!recognitionRef.current) return;
if (isRecording) {
// Usuário clicou para PARAR
setIsRecording(false); // <-- Seta o estado
recognitionRef.current.stop(); // <-- Para a API
// O 'onend' será chamado e fará a limpeza/confirmação.
} else {
// Usuário clicou para COMEÇAR
editor?.chain().focus().run();
setIsRecording(true); // <-- Seta o estado
recognitionRef.current.start(); // <-- Inicia a API
}
};
useEffect(() => {
if (editor && Laudos?.content_html) {
editor.commands.setContent(Laudos.content_html);
}
}, [editor, Laudos]);
const comandos = {
toggleBold: () => editor.chain().focus().toggleBold().run(),
toggleItalic: () => editor.chain().focus().toggleItalic().run(),
toggleUnderline: () => editor.chain().focus().toggleUnderline().run(),
toggleCodeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
toggleH1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
toggleH2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
toggleH3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
toggleParrafo: () => editor.chain().focus().setParagraph().run(),
toggleListaOrdenada: () => editor.chain().focus().toggleOrderedList().run(),
toggleListaPuntos: () => editor.chain().focus().toggleBulletList().run(),
agregarImagen: (url) => {
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
},
agregarLink: () => {
const url = window.prompt('URL do link')
if (url) {
editor.chain().focus().setLink({ href: url }).run()
}
}
}
const handleChange = (e) => {
const { name, value } = e.target;
setLaudos((prev) => ({
...prev,
[name]: value
}));
};
const [pacientesMap, setPacientesMap] = useState({});
useEffect(() => {
if (!Laudos || !Laudos.patient_id) return;
const buscarPacientes = async () => {
try {
// Pega IDs únicos de pacientes
const idsUnicos = [Laudos.patient_id];
// Faz apenas 1 fetch por paciente
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
{
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await res.json();
return { id, full_name: data[0]?.full_name || "Nome não encontrado" };
} catch (err) {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setPacientesMap(map);
} catch (err) {
console.error("Erro ao buscar pacientes:", err);
}
};
buscarPacientes();
}, [Laudos]);
const handleSubmit = (e) => {
e.preventDefault()
Swal.fire({
title: "Você deseja salvar as alterações?",
text: "As modificações serão salvas permanentemente.",
icon: "question",
showDenyButton: true,
showCancelButton: true,
cancelButtonText: "Cancelar",
confirmButtonText: "Salvar",
denyButtonText: "Não salvar",
}).then(async (result) => {
if (result.isConfirmed) {
try {
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify({
patient_id: Laudos.patient_id,
exam: Laudos.exam,
diagnosis: Laudos.diagnosis,
conclusion: Laudos.conclusion,
content_html: Laudos.content_html,
status: "draft",
});
var requestOptions = {
method: "PATCH",
headers: myHeaders,
body: raw,
redirect: "follow",
};
const response = await fetch(
`${supabaseUrl}/rest/v1/reports?id=eq.${id}`,
requestOptions
);
if (response.ok) {
Swal.fire("Salvo!", "", "success").then(() => {
navigate(`/${role}/laudolist`);
})
} else {
Swal.fire("Error saving changes", "", "error");
}
} catch (error) {
Swal.fire("Something went wrong", "", "error");
console.error(error);
}
} else if (result.isDenied) {
Swal.fire("As alterações não foram salvas", "", "info");
}
});
}
return (
<div className="page-wrapper">
<div className="content">
<h4 className="page-title">Laudo Médico</h4>
<div className="d-flex flex-column align-items-left mt-5">
<Card style={{ width: "100%", borderRadius: "10px" }}>
<Card.Header
onClick={() => setOpen(!open)}
aria-controls="paciente-content"
aria-expanded={open}
className="d-flex justify-content-between align-items-center"
style={{
cursor: "pointer",
borderRadius: "25px",
padding: "12px 20px",
}}
>
<span>Informações do paciente</span>
{open ? <ChevronUp /> : <ChevronDown />}
</Card.Header>
<Collapse in={open}>
<div id="paciente-content" className="p-3">
<label>Nome do paciente</label>
<input
className="form-control mb-2"
name='patient_id'
type='text'
id='patient_id'
placeholder="Pesquisar paciente..."
value={pacientesMap[Laudos.patient_id]}>
</input>
<label>Diagnóstico</label>
<input
type="text"
className="form-control mb-2"
placeholder="Diagnóstico"
name='diagnosis'
id='diagnosis'
value={Laudos.diagnosis}
onChange={handleChange}
/>
<label>Exames</label>
<input
type="text"
className="form-control mb-2"
name='exam'
id='exam'
value={Laudos.exam}
onChange={handleChange}
placeholder="Exame"
/>
<label>Conclusão</label>
<input
type="text"
className="form-control mb-2"
name='conclusion'
id='conclusion'
value={Laudos.conclusion}
onChange={handleChange}
placeholder="Conclusão"
/>
</div>
</Collapse>
</Card>
</div>
<Bar comandos={comandos} handleSubmit={handleSubmit} toggleRecording={toggleRecording} isRecording={isRecording} />
<EditorContent editor={editor} />
</div>
</div>
);
}
export default LaudoAdmEdit;

View File

@ -1,23 +1,47 @@
import { useState } from "react";
import { useEffect } from "react";
import "../../assets/css/index.css"
import "../../assets/css/index.css";
import { withMask } from "use-mask-input";
import supabase from "../../Supabase"
import { Link } from "react-router-dom";
import { useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
const AvatarForm = "/img/AvatarForm.jpg";
const AnexoDocumento = "/img/AnexoDocumento.png";
import { useResponsive } from '../../utils/useResponsive.js';
import { getUserRole } from "../../utils/userInfo.js";
function PatientEdit() {
//testando
const role = getUserRole();
const tokenUsuario = getAccessToken()
const [patients, setpatients] = useState([""])
const{id} = useParams()
const { id } = useParams()
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
// carregando a lista e adicionando no usestate
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
// eh carregda a lista
useEffect(() => {
fetch(`https://mock.apidog.com/m1/1053378-0-default/pacientes/${id}`)
.then((response) => response.json())
.then((result) => setpatients(result.data || {}))
.catch((error) => console.log("error", error));
}, [id]);
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions)
.then(response => response.json())
.then(result => {
const paciente = result.find(p => p.id == id);
setpatients(paciente || []);
console.log(paciente)
})
.catch(error => console.log('error', error));
}, [])
const [preview, setPreview] = useState(null);
const navigate = useNavigate();
useEffect(() => {
if (patients.foto_url) {
setPreview('https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSr6OBNqnFlVKC6fAk-mzSuzmOKgjWMYq9y0g&s');
@ -25,20 +49,53 @@ function PatientEdit() {
}, [patients.foto_url]);
const handleEdit = async (e) => {
const requestOptions = {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patients),
redirect: "follow",
};
fetch(`https://mock.apidog.com/m1/1053378-0-default/pacientes/${id}`, requestOptions)
.then((response) => response.json())
.then((result) => {
alert("Paciente editado com sucesso!");
// redireciona após editar
})
.catch((error) => console.log("error", error));
};
e.preventDefault()
Swal.fire({
title: "Você deseja salvar as alterações?",
text: "As modificações serão salvas permanentemente.",
icon: "question",
showDenyButton: true,
showCancelButton: true,
cancelButtonText: "Cancelar",
confirmButtonText: "Salvar",
denyButtonText: "Não salvar",
}).then(async (result) => {
if (result.isConfirmed) {
try {
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify(patients);
var requestOptions = {
method: "PATCH",
headers: myHeaders,
body: raw,
redirect: "follow",
};
const response = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
requestOptions
);
if (response.ok) {
Swal.fire("Salvo!", "", "success").then(() => {
navigate(`/${role}/patientlist`);
})
} else {
Swal.fire("Error saving changes", "", "error");
}
} catch (error) {
Swal.fire("Something went wrong", "", "error");
console.error(error);
}
} else if (result.isDenied) {
Swal.fire("As alterações não foram salvas", "", "info");
}
});
}
// aqui eu fiz uma funçao onde atualiza o estado do paciente, eu poderia ir mudando com o onchange em cada input mas assim ficou melhor
// e como se fosse 'onChange={(e) => setpatientData({ ...patientData, rg: e.target.value })}'
// prev= pega o valor anterio
@ -48,35 +105,36 @@ function PatientEdit() {
...prev,
[name]: value
}));
};
const buscarCep = (e) => {
};
const buscarCep = (e) => {
const cep = patients.cep.replace(/\D/g, '');
console.log(cep);
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then(response => response.json())
.then(data => {
console.log(data)
// salvando os valores para depois colocar nos inputs
setValuesFromCep(data)
// estou salvando os valoeres no patientData
setpatients((prev) => ({
...prev,
cidade: data.localidade || '',
logradouro: data.logradouro || '',
bairro: data.bairro || '',
estado: data.estado || ''
}));
console.log(data)
// salvando os valores para depois colocar nos inputs
setValuesFromCep(data)
// estou salvando os valoeres no patientData
setpatients((prev) => ({
...prev,
city: data.localidade || '',
street: data.logradouro || '',
neighborhood: data.bairro || '',
state: data.estado || ''
}));
})
}
}
// aqui esta sentando os valores nos inputs
const setValuesFromCep = (data) => {
document.getElementById('logradouro').value = data.logradouro || '';
document.getElementById('bairro').value = data.bairro || '';
document.getElementById('cidade').value = data.localidade || '';
document.getElementById('estado').value = data.uf || '';
document.getElementById('street').value = data.logradouro || '';
document.getElementById('neighborhood').value = data.bairro || '';
document.getElementById('city').value = data.localidade || '';
document.getElementById('state').value = data.uf || '';
}
// aqui eu fiz uma funçao onde atualiza o estado do paciente, eu poderia ir mudando com o onchange em cada input mas assim ficou melhor
// e como se fosse 'onChange={(e) => setpatientData({ ...patientData, rg: e.target.value })}'
// prev= pega o valor anterio
const validarCpf = async (cpf) => {
const cpfLimpo = cpf.replace(/\D/g, "");
try {
@ -89,7 +147,7 @@ function PatientEdit() {
});
const data = await response.json();
if (data.valido === false) {
alert("CPF inválido!");
return false;
} else if (data.valido === true) {
@ -106,7 +164,7 @@ function PatientEdit() {
const handleSubmit = async (e) => {
e.preventDefault();
const cpfValido = await validarCpf(patientData.cpf);
// Calcula idade a partir da data de nascimento
@ -152,7 +210,7 @@ function PatientEdit() {
<label>Avatar</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src={preview || "assets/img/user.jpg"} />
<img alt="" src={preview || AvatarForm} />
</div>
<div className="row">
<div className="col-md-9">
@ -160,8 +218,8 @@ function PatientEdit() {
<input
name="foto_url"
onChange={(e) => {
handleChange(e);
setPreview(URL.createObjectURL(e.target.files[0]));
handleChange(e);
setPreview(URL.createObjectURL(e.target.files[0]));
}}
type="file"
accept="image/png, image/jpeg"
@ -193,9 +251,9 @@ function PatientEdit() {
console.log("Erro ao remover foto:", error);
}
}}
>
>
Limpar
</div>
</div>
</div>
</div>
</div>
@ -205,11 +263,11 @@ function PatientEdit() {
<label>
Nome completo<span className="text-danger">*</span>
</label>
<input className="form-control" type="text"
<input className="form-control" type="text"
required
id="nome"
name="nome"
value={patients.nome}
id="full_name"
name="full_name"
value={patients.full_name}
onChange={handleChange}
/>
@ -238,10 +296,10 @@ function PatientEdit() {
<div className="form-group">
<label>Raça</label>
<select
name="raça"
id="raça"
name="ethnicity"
id="ethnicity"
className="form-control"
value={patients.raça}
value={patients.ethnicity}
onChange={handleChange}
>
<option value="">Selecionar</option>
@ -255,16 +313,16 @@ function PatientEdit() {
<div className="form-group">
<label>Profissão</label>
<input className="form-control" type="text"
name="profissao"
value={patients.profissao}
name="profession"
value={patients.profession}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Nome da mãe</label>
<input className="form-control" type="text"
name="nome_mae"
value={patients.nome_mae}
name="mother_name"
value={patients.mother_name}
onChange={handleChange}
/>
@ -272,8 +330,8 @@ function PatientEdit() {
<div className="form-group">
<label>Profissão da mãe</label>
<input className="form-control" type="text"
name="profissao_mae"
value={patients.profissao_mae}
name="mother_profession"
value={patients.mother_profession}
onChange={handleChange}
/>
@ -281,8 +339,8 @@ function PatientEdit() {
<div className="form-group">
<label>Nome do responsável</label>
<input className="form-control" type="text"
name="nome_responsavel"
value={patients.nome_responsavel}
name="guardian_name"
value={patients.guardian_name}
onChange={handleChange}
/>
</div>
@ -303,8 +361,8 @@ function PatientEdit() {
<div className="form-group">
<label>Nome social</label>
<input className="form-control" type="text"
name="nome_social"
value={patients.nome_social}
name="social_name"
value={patients.social_name}
onChange={handleChange}
/>
@ -320,15 +378,15 @@ function PatientEdit() {
<div className="form-group">
<label>Número do documento</label>
<input className="form-control" type="text"
name="numero_documento"
value={patients.numero_documento}
name="document_number"
value={patients.document_number}
onChange={handleChange} />
</div>
<div className="form-group">
<label>Estado civil</label>
<select id="civil" className="form-control"
name="estado_civil"
value={patients.estado_civil}
<select id="marital_status" className="form-control"
name="marital_status"
value={patients.marital_status}
onChange={handleChange}
>
<option value="">Selecionar</option>
@ -341,8 +399,8 @@ function PatientEdit() {
<div className="form-group">
<label>Data de Nascimento</label>
<input type="date" className="form-control"
name="data_nascimento"
value={patients.data_nascimento}
name="birth_date"
value={patients.birth_date}
onChange={handleChange}
/>
</div>
@ -350,31 +408,31 @@ function PatientEdit() {
<label>Nome do pai</label>
<input className="form-control" type="text"
name="nome_pai"
value={patients.nome_pai}
name="father_name"
value={patients.father_name}
onChange={handleChange} />
</div>
<div className="form-group">
<label>Profissão do pai</label>
<input className="form-control" type="text"
name="profissao_pai"
value={patients.profissao_pai}
name="father_profession"
value={patients.father_profession}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>CPF do responsável</label>
<input className="form-control" type="text" ref={withMask('cpf')}
name="cpf_responsavel"
value={patients.cpf_responsavel}
name="guardian_cpf"
value={patients.guardian_cpf}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Código legado</label>
<input className="form-control" type="text"
name="codigo_legado"
value={patients.codigo_legado}
name="legacy_code"
value={patients.legacy_code}
onChange={handleChange}
/>
</div>
@ -382,27 +440,27 @@ function PatientEdit() {
<label className="gen-label">Sexo:</label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"masculino"}
checked={patients.sexo === "masculino"}
<input type="radio" name="sex" className="form-check-input"
value={"Masculino"}
checked={patients.sex === "Masculino"}
onChange={handleChange}
/> Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"feminino"}
checked={patients.sexo === "feminino"}
<input type="radio" name="sex" className="form-check-input"
value={"Feminino"}
checked={patients.sex === "Feminino"}
onChange={handleChange}
/> Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
<input type="radio" name="sex" className="form-check-input"
value={"outro"}
checked={patients.sexo === "outro"}
checked={patients.sex === "Outro"}
onChange={handleChange}
/> Outro
</label>
@ -417,15 +475,15 @@ function PatientEdit() {
<div className="form-group">
<label>Celular</label>
<input className="form-control" type="text" ref={withMask('+55 (99) 99999-9999')}
name="celular"
value={patients.celular}
name="phone_mobile"
value={patients.phone_mobile}
onChange={handleChange} />
</div>
<div className="form-group">
<label>Telefone 1</label>
<input className="form-control" type="text" ref={withMask('+55 (99) 99999-9999')}
name="telefone1"
value={patients.telefone1}
name="phone1"
value={patients.phone1}
onChange={handleChange}
/>
</div>
@ -433,7 +491,7 @@ function PatientEdit() {
<div className="col-sm-6">
<div className="form-group">
<label>Email</label>
<input className="form-control" type="email"
<input className="form-control" type="email"
name="email"
value={patients.email}
onChange={handleChange}
@ -442,8 +500,8 @@ function PatientEdit() {
<div className="form-group">
<label>Telefone 2</label>
<input className="form-control" type="text" ref={withMask('+55 (99) 99999-9999')}
name="telefone2"
value={patients.telefone2}
name="phone2"
value={patients.phone2}
onChange={handleChange}
/>
</div>
@ -467,27 +525,27 @@ function PatientEdit() {
<div className="form-group">
<label>Cidade</label>
<input className="form-control" type="text"
id="cidade"
name="cidade"
value={patients.cidade}
id="city"
name="city"
value={patients.city}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Logradouro</label>
<input className="form-control" type="text"
id="logradouro"
name="logradouro"
value={patients.logradouro}
id="street"
name="street"
value={patients.street}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Complemento</label>
<input className="form-control" type="text"
id="complemento"
name="complemento"
value={patients.complemento}
id="complement"
name="complement"
value={patients.complement}
onChange={handleChange}
/>
</div>
@ -495,35 +553,35 @@ function PatientEdit() {
<div className="col-sm-6">
<div className="form-group">
<label>Estado</label>
<input className="form-control" type="text"
id="estado"
name="estado"
value={patients.estado}
<input className="form-control" type="text"
id="state"
name="state"
value={patients.state}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Número</label>
<input className="form-control" type="text"
name="numero"
value={patients.numero}
name="number"
value={patients.number}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Bairro</label>
<input className="form-control" type="text"
id="bairro"
name="bairro"
value={patients.bairro}
id="neighborhood"
name="neighborhood"
value={patients.neighborhood}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label>Referência </label>
<input className="form-control" type="text"
name="referencia"
value={patients.referencia}
name="reference"
value={patients.reference}
onChange={handleChange}
/>
</div>
@ -578,7 +636,7 @@ function PatientEdit() {
<label>Documentos</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src="assets/img/user.jpg" />
<img alt="" src={AnexoDocumento} />
</div>
<div className="upload-input">
<input type="file" accept="image/png, image/jpeg" className="form-control" />
@ -589,12 +647,11 @@ function PatientEdit() {
<div className="m-t-20 text-center">
<Link to="/patientlist">
<button
className="btn btn-primary submit-btn"
onClick={handleEdit}
>Editar Paciente</button>
</Link>
<Link to="/admin/patientlist"><button
type="button"
className="btn btn-primary submit-btn"
onClick={handleEdit}
>Editar Paciente</button></Link>
</div>
</form>
</div>
@ -603,6 +660,6 @@ function PatientEdit() {
</div>
</div>
);
};
}
export default PatientEdit;

View File

@ -0,0 +1,250 @@
import "../../assets/css/index.css"
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import { getUserRole } from "../../utils/userInfo.js";
function AgendaForm() {
const [doctors, setDoctors] = useState([]);
const [doctorId, setDoctorId] = useState("");
const [weekday, setWeekday] = useState("");
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const [appointmentType, setAppointmentType] = useState("presencial");
const [active, setActive] = useState(true);
const [loading, setLoading] = useState(true);
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const navigate = useNavigate();
const tokenUsuario = getAccessToken();
const headers = {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
"Content-Type": "application/json",
};
// Buscar médicos
useEffect(() => {
setLoading(true);
fetch(`${supabaseUrl}/rest/v1/doctors`, {
headers,
})
.then(async (res) => {
if (!res.ok) {
const text = await res.text();
throw new Error(`Erro ${res.status}: ${text}`);
}
return res.json();
})
.then((data) => {
console.log("Médicos carregados:", data);
setDoctors(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("Erro ao carregar médicos:", err);
setDoctors([]);
})
.finally(() => setLoading(false));
}, []);
// Criar disponibilidade
const handleSubmit = async (e) => {
e.preventDefault();
if (!doctorId || !weekday || !startTime || !endTime) {
alert("Preencha todos os campos obrigatórios!");
return;
}
// Tenta pegar o ID do usuário logado, se existir
// (caso o tokenUsuario contenha JWT com o UUID do usuário)
let createdBy = null;
try {
const payload = JSON.parse(atob(tokenUsuario.split(".")[1]));
createdBy = payload?.sub || null;
} catch (error) {
console.warn("Token inválido ou sem UUID. Usando null para created_by.");
}
const body = {
doctor_id: doctorId,
weekday,
start_time: startTime,
end_time: endTime,
slot_minutes: 30,
appointment_type: appointmentType,
active,
created_by: createdBy, // Envia null se não houver UUID válido
};
console.log("Enviando agenda:", body);
fetch(
`${supabaseUrl}/rest/v1/doctor_availability`,
{
method: "POST",
headers,
body: JSON.stringify(body),
}
)
.then(async (res) => {
const text = await res.text();
if (!res.ok) {
throw new Error(`Erro ${res.status}: ${text}`);
}
return text ? JSON.parse(text) : {};
})
.then(() => {
alert("✅ Agenda criada com sucesso!");
navigate(`/${role}/agendadoctor`);
})
.catch((err) => {
console.error("❌ Erro ao criar agenda:", err);
alert("Erro ao criar agenda. Veja o console para mais detalhes.");
});
};
return (
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h4 className="page-title">Adicionar Agenda</h4>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleSubmit}>
{/* Médico */}
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Médico</label>
<select
className="form-control"
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
required
>
<option value="">
{loading ? "Carregando..." : "Selecionar"}
</option>
{!loading && doctors.length === 0 && (
<option disabled>Nenhum médico encontrado</option>
)}
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.full_name || doc.name || `ID: ${doc.id}`}
</option>
))}
</select>
</div>
</div>
{/* Dias */}
<div className="col-md-6">
<div className="form-group">
<label>Dias disponíveis</label>
<select
className="form-control"
value={weekday}
onChange={(e) => setWeekday(e.target.value)}
required
>
<option value="">Selecionar</option>
<option value="monday">Segunda</option>
<option value="tuesday">Terça</option>
<option value="wednesday">Quarta</option>
<option value="thursday">Quinta</option>
<option value="friday">Sexta</option>
<option value="saturday">Sábado</option>
<option value="sunday">Domingo</option>
</select>
</div>
</div>
</div>
{/* Horários */}
<div className="row">
<div className="col-md-6">
<label>Início</label>
<input
type="time"
className="form-control"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="col-md-6">
<label>Fim</label>
<input
type="time"
className="form-control"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
{/* Tipo e Status */}
<div className="form-group mt-3">
<label>Tipo de consulta</label>
<select
className="form-control"
value={appointmentType}
onChange={(e) => setAppointmentType(e.target.value)}
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
<div className="form-group">
<label>Status</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
value="ativo"
checked={active}
onChange={() => setActive(true)}
/>
<label className="form-check-label">Ativo</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
value="inativo"
checked={!active}
onChange={() => setActive(false)}
/>
<label className="form-check-label">Inativo</label>
</div>
</div>
<div className="text-center mt-4">
<button className="btn btn-primary submit-btn" type="submit">
Criar agenda
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}
export default AgendaForm;

View File

@ -0,0 +1,477 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Swal from "sweetalert2";
import "../../assets/css/index.css";
import { getAccessToken } from "../../utils/auth";
import { getUserId } from "../../utils/userInfo";
import { sendSMS } from "../../utils/sendSMS";
import { getUserRole } from "../../utils/userInfo";
import emailjs from 'emailjs-com';
function ConsultaForm() {
const role = getUserRole();
const [minDate, setMinDate] = useState("");
const [pacientes, setPacientes] = useState([]);
const [medicos, setMedicos] = useState([]);
const [horariosDisponiveis, setHorariosDisponiveis] = useState([]);
const [apiResponse, setApiResponse] = useState(null);
const [carregandoHorarios, setCarregandoHorarios] = useState(false);
const tokenUsuario = getAccessToken();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const servicekey = import.meta.env.VITE_SERVICE_KEY
const templatekey = import.meta.env.VITE_TEMPLATE_KEY
const publickey = import.meta.env.VITE_PUBLIC_KEY
const [formData, setFormData] = useState({
appointment_type: "presencial",
chief_complaint: "",
doctor_id: "",
duration_minutes: 30,
insurance_provider: "",
patient_id: "",
patient_notes: "",
scheduled_date: "",
scheduled_time: "",
});
const navigate = useNavigate();
// 🔹 Define a data mínima
useEffect(() => {
const today = new Date();
const offset = today.getTimezoneOffset();
today.setMinutes(today.getMinutes() - offset);
setMinDate(today.toISOString().split("T")[0]);
}, []);
// 🔹 Buscar pacientes
useEffect(() => {
const fetchPacientes = async () => {
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/patients`,
{
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
if (response.ok) {
const data = await response.json();
setPacientes(data);
} else {
console.error("Erro ao buscar pacientes");
}
} catch (error) {
console.error("Erro:", error);
}
};
fetchPacientes();
}, []);
// 🔹 Buscar médicos
useEffect(() => {
const fetchMedicos = async () => {
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/doctors`,
{
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
if (response.ok) {
const data = await response.json();
setMedicos(data);
} else {
console.error("Erro ao buscar médicos");
}
} catch (error) {
console.error("Erro:", error);
}
};
fetchMedicos();
}, []);
// 🔹 Buscar horários disponíveis
const fetchHorariosDisponiveis = async (doctorId, date, appointmentType) => {
if (!doctorId || !date) {
setHorariosDisponiveis([]);
setApiResponse(null);
return;
}
setCarregandoHorarios(true);
const startDate = `${date}T00:00:00.000Z`;
const endDate = `${date}T23:59:59.999Z`;
const payload = {
doctor_id: doctorId,
start_date: startDate,
end_date: endDate,
appointment_type: appointmentType || "presencial",
};
console.log("🚀 AgendaForm - Payload enviado para get-available-slots:", payload);
console.log("🔑 AgendaForm - Token do usuário:", tokenUsuario ? "EXISTS" : "NULL");
try {
const response = await fetch(
`${supabaseUrl}/functions/v1/get-available-slots`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
body: JSON.stringify(payload),
}
);
const data = await response.json();
setApiResponse(data);
console.log("🔍 AgendaForm (Admin) - Resposta da Edge Function:", data);
if (!response.ok) throw new Error(data.error || "Erro ao buscar horários");
const slotsDisponiveis = (data?.slots || []).filter((s) => s.available);
console.log("✅ AgendaForm (Admin) - Slots disponíveis após filtro:", slotsDisponiveis);
console.log("🔍 AgendaForm (Admin) - Todos os slots (antes do filtro):", data?.slots);
console.log("❌ AgendaForm (Admin) - Slots NÃO disponíveis:", (data?.slots || []).filter((s) => !s.available));
setHorariosDisponiveis(slotsDisponiveis);
if (slotsDisponiveis.length === 0)
Swal.fire("Atenção", "Nenhum horário disponível para este dia.", "info");
} catch (error) {
console.error("Erro ao buscar horários disponíveis:", error);
setHorariosDisponiveis([]);
setApiResponse(null);
Swal.fire("Erro", "Não foi possível obter os horários disponíveis.", "error");
} finally {
setCarregandoHorarios(false);
}
};
// 🔹 Atualiza campos
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// 🔹 Atualiza horários quando médico ou data mudam
useEffect(() => {
if (formData.doctor_id && formData.scheduled_date) {
fetchHorariosDisponiveis(
formData.doctor_id,
formData.scheduled_date,
formData.appointment_type
);
}
}, [formData.doctor_id, formData.scheduled_date, formData.appointment_type]);
// 🔹 Envia formulário
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.scheduled_date || !formData.scheduled_time) {
Swal.fire("Atenção", "Selecione uma data e horário válidos", "warning");
return;
}
const scheduled_at = `${formData.scheduled_date}T${formData.scheduled_time}:00Z`;
const payload = {
patient_id: formData.patient_id,
doctor_id: formData.doctor_id,
scheduled_at,
duration_minutes: formData.duration_minutes,
appointment_type: formData.appointment_type,
chief_complaint: formData.chief_complaint,
patient_notes: formData.patient_notes,
insurance_provider: formData.insurance_provider,
created_by: getUserId(),
};
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
Prefer: "return=representation",
},
body: JSON.stringify(payload),
}
);
if (response.ok) {
const consultaCriada = await response.json();
// 🔹 Busca o telefone do paciente selecionado
const pacienteSelecionado = pacientes.find(
(p) => String(p.id) === String(formData.patient_id)
);
const email =
pacienteSelecionado?.email ||
null;
if (email){
try {
await emailjs.send(
`${servicekey}`,
`${templatekey}`,
{
nome_do_paciente: pacienteSelecionado.name || pacienteSelecionado.full_name || `Paciente #${pacienteSelecionado.id}`,
date: formData.scheduled_date,
time: formData.scheduled_time,
doctor_name: medicos.find((m) => String(m.id) === String(formData.doctor_id))?.full_name ||
medicos.find((m) => String(m.id) === String(formData.doctor_id))?.full_name ||
medicos.find((m) => String(m.id) === String(formData.doctor_id))?.doctor_name ||
`Médico #${formData.doctor_id}`,
email: email,
},
`${publickey}`
);
console.log("Email de confirmação enviado com sucesso!");
} catch (error) {
console.error("Erro ao enviar email de confirmação:", error);
}
}
Swal.fire({
title: "Sucesso!",
text: "Consulta criada com sucesso!",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
navigate(`/${role}/consultalist`);
});
} else {
const error = await response.json();
console.error(error);
Swal.fire("Erro", "Não foi possível criar a consulta", "error");
}
} catch (error) {
console.error(error);
Swal.fire("Erro", "Erro de conexão com o servidor", "error");
}
};
return (
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h1>Nova consulta</h1>
<hr />
<h3>Informações do paciente</h3>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleSubmit}>
<div className="row">
{/* Paciente */}
<div className="col-md-6">
<div className="form-group">
<label>
Nome do paciente<span className="text-danger">*</span>
</label>
<select
className="select form-control"
name="patient_id"
value={formData.patient_id}
onChange={handleChange}
required
>
<option value="">Selecione o paciente</option>
{pacientes.map((p) => {
const nomePaciente =
p.name ||
p.nome ||
p.full_name ||
p.paciente_nome ||
`Paciente #${p.id}`;
return (
<option key={p.id} value={p.id}>
{nomePaciente}
</option>
);
})}
</select>
</div>
</div>
{/* Tipo da consulta */}
<div className="col-md-6">
<div className="form-group">
<label>Tipo da consulta</label>
<select
className="select form-control"
name="appointment_type"
value={formData.appointment_type}
onChange={handleChange}
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
</div>
</div>
<hr />
<h3>Informações do atendimento</h3>
{/* Médico e Convênio */}
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>
Médico<span className="text-danger">*</span>
</label>
<select
className="select form-control"
name="doctor_id"
value={formData.doctor_id}
onChange={handleChange}
required
>
<option value="">Selecione o médico</option>
{medicos.map((m) => {
const nomeMedico =
m.name ||
m.nome ||
m.full_name ||
m.doctor_name ||
`Médico #${m.id}`;
return (
<option key={m.id} value={m.id}>
{nomeMedico}
</option>
);
})}
</select>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Convênio</label>
<input
type="text"
className="form-control"
name="insurance_provider"
value={formData.insurance_provider}
onChange={handleChange}
placeholder="Ex: Unimed, Bradesco..."
/>
</div>
</div>
</div>
{/* Motivo */}
<div className="form-group">
<label>Motivo / Queixa principal</label>
<input
type="text"
className="form-control"
name="chief_complaint"
value={formData.chief_complaint}
onChange={handleChange}
required
/>
</div>
{/* Data e hora */}
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Data</label>
<input
type="date"
className="form-control"
min={minDate}
name="scheduled_date"
value={formData.scheduled_date}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Horário</label>
<select
className="select form-control"
name="scheduled_time"
value={formData.scheduled_time}
onChange={handleChange}
required
disabled={carregandoHorarios || !horariosDisponiveis.length}
>
<option value="">
{carregandoHorarios
? "Carregando horários..."
: horariosDisponiveis.length
? "Selecione um horário"
: "Nenhum horário disponível"}
</option>
{horariosDisponiveis.map((slot) => {
const hora = slot.datetime.split("T")[1].substring(0, 5);
return (
<option key={slot.datetime} value={hora}>
{hora}
</option>
);
})}
</select>
</div>
</div>
</div>
<div className="form-group">
<label>Anotações do paciente</label>
<textarea
cols="30"
rows="4"
className="form-control"
name="patient_notes"
value={formData.patient_notes}
onChange={handleChange}
></textarea>
</div>
<div className="m-t-20 text-center">
<button className="btn btn-primary submit-btn" type="submit">
Criar consulta
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}
export default ConsultaForm;

View File

@ -0,0 +1,488 @@
import "../../assets/css/index.css";
import { withMask } from "use-mask-input";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
import { getUserRole } from "../../utils/userInfo.js";
const role = getUserRole();
function DoctorForm() {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const role = getUserRole();
const [doctorData, setDoctorData] = useState({
full_name: "",
cpf: "",
email: "",
crm: "",
crm_uf: "",
specialty: "",
birth_date: "",
phone_mobile: "",
cep: "",
street: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
active: false,
});
const tokenUsuario = getAccessToken();
const navigate = useNavigate();
const estados = {
AC: "Acre", AL: "Alagoas", AP: "Amapá", AM: "Amazonas",
BA: "Bahia", CE: "Ceará", DF: "Distrito Federal", ES: "Espírito Santo",
GO: "Goiás", MA: "Maranhão", MT: "Mato Grosso", MS: "Mato Grosso do Sul",
MG: "Minas Gerais", PA: "Pará", PB: "Paraíba", PR: "Paraná",
PE: "Pernambuco", PI: "Piauí", RJ: "Rio de Janeiro", RN: "Rio Grande do Norte",
RS: "Rio Grande do Sul", RO: "Rondônia", RR: "Roraima", SC: "Santa Catarina",
SP: "São Paulo", SE: "Sergipe", TO: "Tocantins"
};
const buscarCep = () => {
const cep = doctorData.cep.replace(/\D/g, "");
if (cep.length === 8) {
fetch(`https://brasilapi.com.br/api/cep/v2/${cep}`)
.then((response) => response.json())
.then((data) => {
setDoctorData((prev) => ({
...prev,
city: data.city || '',
street: data.street || '',
neighborhood: data.neighborhood || '',
state: estados[data.state] || data.state
}));
})
.catch(() => {
Swal.fire({ title: "Erro ao buscar CEP", icon: "error" });
});
}
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setDoctorData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
const requiredFields = [
"full_name", "cpf", "email", "phone_mobile", "crm", "crm_uf",
"specialty", "birth_date", "cep", "street", "number",
"neighborhood", "city", "state"
];
const missingFields = requiredFields.filter(
(field) => !doctorData[field] || doctorData[field].toString().trim() === ""
);
if (missingFields.length > 0) {
Swal.fire({
title: "Campos obrigatórios faltando",
text: "Por favor, preencha todos os campos antes de continuar.",
icon: "warning"
});
return;
}
try {
// === 2 ETAPA 1: CRIAR O USUÁRIO NO AUTH (CHAMANDO A FUNCTION) ===
const authHeaders = new Headers();
authHeaders.append("apikey", supabaseAK);
authHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
authHeaders.append("Content-Type", "application/json");
const authRaw = JSON.stringify({
...doctorData,
password: "12345678", // Usando CRM como senha
role: "medico"
});
console.log("📤 Body enviado para Auth:", authRaw);
console.log("🌐 Endpoint:", `${supabaseUrl}/functions/v1/create-user-with-password`);
const authResponse = await fetch(
`${supabaseUrl}/functions/v1/create-user-with-password`,
{
method: 'POST',
headers: authHeaders,
body: authRaw,
redirect: 'follow'
}
);
console.log("📥 Status da resposta:", authResponse.status, authResponse.statusText);
if (!authResponse.ok) {
console.log("❌ Resposta não OK de criação de usuário no Auth");
}else{
navigate(`/${role}/doctorlist`)
}
} catch (error) {
console.error("❌ Erro no cadastro em duas etapas:", error);
Swal.fire({
title: "Erro ao cadastrar",
text: error.message, // Exibe a mensagem de erro específica
icon: "error"
});
}
};
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleSubmit}>
<div className="row">
{/* Nome completo */}
<div className="col-sm-12">
<hr />
<h2>Dados pessoais</h2>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Nome Completo <span className="text-danger">*</span></label>
<input
className="form-control"
type="text"
name="full_name"
value={doctorData.full_name}
onChange={handleChange}
/>
</div>
</div>
{/* CPF */}
<div className="col-sm-6">
<div className="form-group">
<label>CPF <span className="text-danger">*</span></label>
<input
className="form-control"
type="text"
ref={withMask("cpf")}
name="cpf"
value={doctorData.cpf}
onChange={handleChange}
/>
</div>
</div>
{/* Email */}
<div className="col-sm-6">
<div className="form-group">
<label>Email <span className="text-danger">*</span></label>
<input
className="form-control"
type="email"
name="email"
value={doctorData.email}
onChange={handleChange}
/>
</div>
</div>
{/* Telefone */}
<div className="col-sm-6">
<div className="form-group">
<label>Telefone Celular <span className="text-danger">*</span></label>
<input
className="form-control"
type="text"
ref={withMask("+99 (99) 99999-9999")}
name="phone_mobile"
value={doctorData.phone_mobile}
onChange={handleChange}
/>
</div>
</div>
{/* CRM */}
<div className="col-sm-3">
<div className="form-group">
<label>CRM <span className="text-danger">*</span></label>
<input
className="form-control"
type="text"
name="crm"
value={doctorData.crm}
onChange={handleChange}
/>
</div>
</div>
{/* CRM UF */}
<div className="col-sm-3">
<div className="form-group">
<label>CRM - UF <span className="text-danger">*</span></label>
<select
className="form-control"
name="crm_uf"
value={doctorData.crm_uf}
onChange={handleChange}
>
<option value="">Selecione</option>
<option value="AC">AC - Acre</option>
<option value="AL">AL - Alagoas</option>
<option value="AP">AP - Amapá</option>
<option value="AM">AM - Amazonas</option>
<option value="BA">BA - Bahia</option>
<option value="CE">CE - Ceará</option>
<option value="DF">DF - Distrito Federal</option>
<option value="ES">ES - Espírito Santo</option>
<option value="GO">GO - Goiás</option>
<option value="MA">MA - Maranhão</option>
<option value="MT">MT - Mato Grosso</option>
<option value="MS">MS - Mato Grosso do Sul</option>
<option value="MG">MG - Minas Gerais</option>
<option value="PA">PA - Pará</option>
<option value="PB">PB - Paraíba</option>
<option value="PR">PR - Paraná</option>
<option value="PE">PE - Pernambuco</option>
<option value="PI">PI - Piauí</option>
<option value="RJ">RJ - Rio de Janeiro</option>
<option value="RN">RN - Rio Grande do Norte</option>
<option value="RS">RS - Rio Grande do Sul</option>
<option value="RO">RO - Rondônia</option>
<option value="RR">RR - Roraima</option>
<option value="SC">SC - Santa Catarina</option>
<option value="SP">SP - São Paulo</option>
<option value="SE">SE - Sergipe</option>
<option value="TO">TO - Tocantins</option>
</select>
</div>
</div>
{/* Especialidade */}
<div className="col-sm-6">
<div className="form-group">
<label>Especialidade <span className="text-danger">*</span></label>
<select
className="form-control"
name="specialty"
value={doctorData.specialty}
onChange={handleChange}
>
<option value="">Selecione</option>
<option value="Cardiologia">Cardiologia</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Endocrinologia">Endocrinologia</option>
<option value="Gastroenterologia">Gastroenterologia</option>
<option value="Ginecologia">Ginecologia</option>
<option value="Neurologia">Neurologia</option>
<option value="Oftalmologia">Oftalmologia</option>
<option value="Ortopedia">Ortopedia</option>
<option value="Otorrinolaringologia">Otorrinolaringologia</option>
<option value="Pediatria">Pediatria</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Urologia">Urologia</option>
</select>
</div>
</div>
{/* Data de nascimento */}
<div className="col-sm-6">
<div className="form-group">
<label>Data de Nascimento <span className="text-danger">*</span></label>
<input
type="date"
className="form-control"
name="birth_date"
value={doctorData.birth_date}
onChange={handleChange}
/>
</div>
</div>
{/*<div className="col-sm-6">
<div className="form-group gender-select">
<label className="gen-label">Sexo:<span className="text-danger">*</span></label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sex" className="form-check-input"
value={"Masculino"}
checked={doctorData.sex === "Masculino"}
onChange={handleChange}
/> Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sex" className="form-check-input"
value={"Feminino"}
checked={doctorData.sex === "Feminino"}
onChange={handleChange}
/> Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sex" className="form-check-input"
value={"outro"}
checked={doctorData.sex === "outro"}
onChange={handleChange}
/> Outro
</label>
</div>
</div>
</div>*/}
<div className="col-sm-12">
<hr />
<h2>Endereço</h2>
</div>
{/* CEP */}
<div className="col-sm-4">
<div className="form-group">
<label>CEP <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="cep"
value={doctorData.cep}
onChange={handleChange}
onBlur={buscarCep}
/>
</div>
</div>
{/* Rua */}
<div className="col-sm-8">
<div className="form-group">
<label>Logradouro <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="street"
value={doctorData.street}
onChange={handleChange}
/>
</div>
</div>
{/* Número */}
<div className="col-sm-4">
<div className="form-group">
<label>Número <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="number"
value={doctorData.number}
onChange={handleChange}
/>
</div>
</div>
{/* Complemento */}
<div className="col-sm-4">
<div className="form-group">
<label>Complemento<span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="complement"
value={doctorData.complement}
onChange={handleChange}
/>
</div>
</div>
{/* Bairro */}
<div className="col-sm-4">
<div className="form-group">
<label>Bairro <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="neighborhood"
value={doctorData.neighborhood}
onChange={handleChange}
/>
</div>
</div>
{/* Cidade */}
<div className="col-sm-6">
<div className="form-group">
<label>Cidade <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="city"
value={doctorData.city}
onChange={handleChange}
/>
</div>
</div>
{/* Estado */}
<div className="col-sm-6">
<div className="form-group">
<label>Estado <span className="text-danger">*</span></label>
<input
type="text"
className="form-control"
name="state"
value={doctorData.state}
onChange={handleChange}
/>
</div>
</div>
{/* Ativo/Inativo */}
<div className="col-sm-12">
<div className="form-group">
<label className="d-block">Status <span className="text-danger">*</span></label>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="active"
id="ativo"
value="true"
checked={doctorData.active === true}
onChange={() => setDoctorData((prev) => ({ ...prev, active: true }))}
/>
<label className="form-check-label" htmlFor="ativo">Ativo</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="active"
id="inativo"
value="false"
checked={doctorData.active === false}
onChange={() => setDoctorData((prev) => ({ ...prev, active: false }))}
/>
<label className="form-check-label" htmlFor="inativo">Inativo</label>
</div>
</div>
</div>
</div>
<div className="m-t-20 text-center">
<button className="btn btn-primary submit-btn" type="submit">
Cadastrar Médico
</button>
</div>
</form>
</div>
</div>
</div>
</div >
</div >
);
}
export default DoctorForm;

View File

@ -0,0 +1,445 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image';
import { useState, useEffect, useRef } from 'react';
import { Card, Collapse } from "react-bootstrap"; // <-- IMPORT CORRETO
import { ChevronDown, ChevronUp } from "lucide-react";
import { getAccessToken } from '../../utils/auth';
import Select from 'react-select';
import Swal from 'sweetalert2';
import { useNavigate } from 'react-router-dom';
import { FaMicrophone } from "react-icons/fa";
import { InterimMark } from '../../utils/InterimMark'; // <-- Verifique se esse caminho está certo!
import { getUserRole, getDoctorId } from '../../utils/userInfo';
function Bar({ comandos, handleSubmit, toggleRecording, isRecording }) {
const inputRef = useRef(null);
const handleAbrirExplorador = () => {
inputRef.current.click(); // abre o explorador
};
const handleArquivoSelecionado = (event) => {
const arquivo = event.target.files[0];
if (arquivo) {
const imageUrl = URL.createObjectURL(arquivo);
comandos.agregarImagen(imageUrl);
event.target.value = null;
}
};
return (
<>
<div className="toolbar">
<div className="left">
<button onClick={comandos.toggleBold} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 11H12.5C13.8807 11 15 9.88071 15 8.5C15 7.11929 13.8807 6 12.5 6H8V11ZM18 15.5C18 17.9853 15.9853 20 13.5 20H6V4H12.5C14.9853 4 17 6.01472 17 8.5C17 9.70431 16.5269 10.7981 15.7564 11.6058C17.0979 12.3847 18 13.837 18 15.5ZM8 13V18H13.5C14.8807 18 16 16.8807 16 15.5C16 14.1193 14.8807 13 13.5 13H8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleItalic} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 20H7V18H9.92661L12.0425 6H9V4H17V6H14.0734L11.9575 18H15V20Z"></path>
</svg>
</button>
<button onClick={comandos.toggleUnderline} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z"></path>
</svg>
</button>
{/*<button onClick={comandos.toggleCodeBlock} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
</button> */}
<button onClick={comandos.toggleH1} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 20H11V13H4V20H2V4H4V11H11V4H13V20ZM21.0005 8V20H19.0005L19 10.204L17 10.74V8.67L19.5005 8H21.0005Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH2} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4V11H11V4H13V20H11V13H4V20H2V4H4ZM18.5 8C20.5711 8 22.25 9.67893 22.25 11.75C22.25 12.6074 21.9623 13.3976 21.4781 14.0292L21.3302 14.2102L18.0343 18H22V20H15L14.9993 18.444L19.8207 12.8981C20.0881 12.5908 20.25 12.1893 20.25 11.75C20.25 10.7835 19.4665 10 18.5 10C17.5818 10 16.8288 10.7071 16.7558 11.6065L16.75 11.75H14.75C14.75 9.67893 16.4289 8 18.5 8Z"></path>
</svg>
</button>
<button onClick={comandos.toggleH3} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 8L21.9984 10L19.4934 12.883C21.0823 13.3184 22.25 14.7728 22.25 16.5C22.25 18.5711 20.5711 20.25 18.5 20.25C16.674 20.25 15.1528 18.9449 14.8184 17.2166L16.7821 16.8352C16.9384 17.6413 17.6481 18.25 18.5 18.25C19.4665 18.25 20.25 17.4665 20.25 16.5C20.25 15.5335 19.4665 14.75 18.5 14.75C18.214 14.75 17.944 14.8186 17.7056 14.9403L16.3992 13.3932L19.3484 10H15V8H22ZM4 4V11H11V4H13V20H11V13H4V20H2V4H4Z"></path>
</svg>
</button>
<button onClick={comandos.toggleParrafo} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6V21H10V16C6.68629 16 4 13.3137 4 10C4 6.68629 6.68629 4 10 4H20V6H17V21H15V6H12ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14V6Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaOrdenada} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM5 3V6H6V7H3V6H4V4H3V3H5ZM3 14V11.5H5V11H3V10H6V12.5H4V13H6V14H3ZM5 19.5H3V18.5H5V18H3V17H6V21H3V20H5V19.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<button onClick={comandos.toggleListaPuntos} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z"></path>
</svg>
</button>
<>
<input
type="file"
accept="image/*"
ref={inputRef}
onChange={handleArquivoSelecionado}
style={{ display: "none" }} // esconde o input
/>
<button onClick={handleAbrirExplorador}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M2.9918 21C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918ZM20 15V5H4V19L14 9L20 15ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path>
</svg>
</button>
</>
<button
onClick={toggleRecording}
className={`toolbar-button ${isRecording ? "active" : ""}`}
title={isRecording ? "Parar ditado" : "Iniciar ditado por voz"}
>
<FaMicrophone size={18} />
</button>
{/*<button onClick={comandos.agregarLink} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.3638 15.5355L16.9496 14.1213L18.3638 12.7071C20.3164 10.7545 20.3164 7.58866 18.3638 5.63604C16.4112 3.68341 13.2453 3.68341 11.2927 5.63604L9.87849 7.05025L8.46428 5.63604L9.87849 4.22182C12.6122 1.48815 17.0443 1.48815 19.778 4.22182C22.5117 6.95549 22.5117 11.3876 19.778 14.1213L18.3638 15.5355ZM15.5353 18.364L14.1211 19.7782C11.3875 22.5118 6.95531 22.5118 4.22164 19.7782C1.48797 17.0445 1.48797 12.6123 4.22164 9.87868L5.63585 8.46446L7.05007 9.87868L5.63585 11.2929C3.68323 13.2455 3.68323 16.4113 5.63585 18.364C7.58847 20.3166 10.7543 20.3166 12.7069 18.364L14.1211 16.9497L15.5353 18.364ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
</button> */}
</div>
<div className="right">
<button onClick={handleSubmit} className="btnGuardar">
<span>Enviar laudo</span>
</button>
</div>
</div>
</>
);
};
function LaudoForm() {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const navigate = useNavigate();
const [paciente, setPaciente] = useState([]);
const tokenUsuario = getAccessToken()
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions)
.then(response => response.json())
.then(result => setPaciente(Array.isArray(result) ? result : []))
.catch(error => console.log('error', error));
}, [])
const options = paciente.map(p => ({
value: p.id,
label: p.full_name
}));
function gerarOrderNumber() {
const prefixo = "REL";
const agora = new Date();
const ano = agora.getFullYear();
const mes = String(agora.getMonth() + 1).padStart(2, "0"); // adiciona 0 à esquerda se necessário
// Gerar um código aleatório de 6 caracteres (letras maiúsculas + números)
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let codigo = "";
for (let i = 0; i < 6; i++) {
codigo += chars.charAt(Math.floor(Math.random() * chars.length));
}
return `${prefixo}-${ano}-${mes}-${codigo}`;
}
// Exemplo de uso:
const orderNumber = gerarOrderNumber();
const [laudos, setLaudos] = useState({
patient_id: "",
order_number: "",
exam: "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: "",
status: "draft",
requested_by: getDoctorId(),
});
const handlePacienteChange = (selected) => {
setLaudos(prev => ({
...prev,
patient_id: selected ? selected.value : ""
}));
};
const handleChange = (e) => {
const { name, value } = e.target;
setLaudos((prev) => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e) => {
const role = getUserRole();
e.preventDefault();
if (!laudos.patient_id || !laudos.diagnosis || !laudos.exam || !laudos.conclusion) {
Swal.fire({
title: "Por favor, preencha todos os campos obrigatórios.",
icon: "warning",
draggable: true
});
return;
}
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify(laudos);
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: raw,
redirect: 'follow'
};
fetch(`${supabaseUrl}/rest/v1/reports`, requestOptions)
.then(response => response.text())
.then(result => {
console.log(result);
Swal.fire({
title: "Laudo adicionado!",
icon: "success",
draggable: true
});
navigate(`/${role}/laudolist`);
})
.catch(error => console.log('error', error));
};
const [open, setOpen] = useState(false);
const editor = useEditor({
extensions: [StarterKit, Image, InterimMark ],
content: "",
onUpdate: ({ editor }) => {
setLaudos(prev => ({
...prev,
content_html: editor.getHTML()
}));
}
})
const [isRecording, setIsRecording] = useState(false);
const recognitionRef = useRef(null);
const lastInsertedRef = useRef({ from: -1, to: -1, text: '' });
useEffect(() => {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Seu navegador não suporta reconhecimento de voz 😢");
return;
}
const recognition = new SpeechRecognition();
recognition.lang = "pt-BR";
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = (event) => {
if (!editor) return;
const result = event.results[0];
const transcript = result[0].transcript;
const last = lastInsertedRef.current;
// --- CORREÇÃO DE LÓGICA ---
// Vamos rodar a deleção como um comando SEPARADO primeiro.
if (last.from !== -1 &&
editor.state.doc.textBetween(last.from, last.to) === last.text)
{
// Roda a deleção e PARA.
editor.chain().focus()
.deleteRange({ from: last.from, to: last.to })
.run();
}
// Pega a posição ATUAL (depois da deleção)
const currentPos = editor.state.selection.from;
if (result.isFinal) {
// --- RESULTADO FINAL (PRETO) ---
// Roda a inserção final como um comando SEPARADO.
editor.chain().focus()
.insertContent(transcript + ' ')
.run();
// Reseta a Ref
lastInsertedRef.current = { from: -1, to: -1, text: '' };
} else {
// --- RESULTADO PROVISÓRIO (CINZA) ---
// Esta é a nova estratégia: "Ligar" a mark, inserir, "Desligar" a mark.
// Roda tudo como um comando SEPARADO.
editor.chain()
.focus()
.setMark('interimMark') // <-- "Pincel cinza" LIGADO
.insertContent(transcript) // <-- Insere o texto
.unsetMark('interimMark') // <-- "Pincel cinza" DESLIGADO
.run();
// Atualiza a Ref com a posição do texto cinza
lastInsertedRef.current = {
from: currentPos,
to: currentPos + transcript.length,
text: transcript
};
}
// Não precisamos mais do 'editorChain.run()' aqui embaixo
};
recognition.onerror = (err) => {
// ... (código do onerror sem mudanças)
};
recognition.onend = () => {
// ... (código do onend sem mudanças)
};
recognitionRef.current = recognition;
return () => {
recognition.stop();
};
}, [editor, isRecording]);
const toggleRecording = () => {
if (!recognitionRef.current) return;
if (isRecording) {
// Usuário clicou para PARAR
setIsRecording(false); // <-- Seta o estado
recognitionRef.current.stop(); // <-- Para a API
// O 'onend' será chamado e fará a limpeza/confirmação.
} else {
// Usuário clicou para COMEÇAR
editor?.chain().focus().run();
setIsRecording(true); // <-- Seta o estado
recognitionRef.current.start(); // <-- Inicia a API
}
};
const comandos = {
toggleBold: () => editor.chain().focus().toggleBold().run(),
toggleItalic: () => editor.chain().focus().toggleItalic().run(),
toggleUnderline: () => editor.chain().focus().toggleUnderline().run(),
toggleCodeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
toggleH1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
toggleH2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
toggleH3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
toggleParrafo: () => editor.chain().focus().setParagraph().run(),
toggleListaOrdenada: () => editor.chain().focus().toggleOrderedList().run(),
toggleListaPuntos: () => editor.chain().focus().toggleBulletList().run(),
agregarImagen: (url) => {
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
},
agregarLink: () => {
const url = window.prompt('URL do link')
if (url) {
editor.chain().focus().setLink({ href: url }).run()
}
}
}
return (
<div className="page-wrapper">
<div className="content">
<h4 className="page-title">Laudo Médico</h4>
<div className="d-flex flex-column align-items-left mt-5">
<Card style={{ width: "100%", borderRadius: "10px" }}>
<Card.Header
onClick={() => setOpen(!open)}
aria-controls="paciente-content"
aria-expanded={open}
className="d-flex justify-content-between align-items-center"
style={{
cursor: "pointer",
borderRadius: "25px",
padding: "12px 20px",
}}
>
<span>Informações do paciente</span>
{open ? <ChevronUp /> : <ChevronDown />}
</Card.Header>
<Collapse in={open}>
<div id="paciente-content" className="p-3">
<Select
options={options}
placeholder="Pesquisar paciente..."
isClearable
isSearchable
onChange={handlePacienteChange}
value={options.find(option => option.value === laudos.patient_id) || null}>
</Select>
<input
type="text"
className="form-control mb-2"
placeholder="Diagnóstico"
name='diagnosis'
value={laudos.diagnosis}
onChange={handleChange}
/>
<input
type="text"
className="form-control mb-2"
name='exam'
value={laudos.exam}
onChange={handleChange}
placeholder="Exame"
/>
<input
type="text"
className="form-control mb-2"
name='conclusion'
value={laudos.conclusion}
onChange={handleChange}
placeholder="Conclusão"
/>
</div>
</Collapse>
</Card>
</div>
<Bar comandos={comandos} handleSubmit={handleSubmit} toggleRecording={toggleRecording} isRecording={isRecording} />
<EditorContent editor={editor} />
</div>
</div>
);
}
export default LaudoForm;
;

View File

@ -0,0 +1,465 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useLocation, Link } from "react-router-dom";
import "../../assets/css/index.css";
import { logoutUser } from "../../Supabase";
import Swal from "sweetalert2";
import { getUserRole, clearUserInfo, getUserId } from "../../utils/userInfo";
import { getAccessToken } from "../../utils/auth";
const AvatarForm = "/img/AvatarForm.jpg";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
var myHeaders = new Headers();
const tokenUsuario = getAccessToken();
const LS_KEYS = {
dark: "pref_dark_mode",
daltonism: "pref_daltonism",
font: "pref_font_scale",
};
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
function Navbar({ onMenuClick }) {
const location = useLocation();
const navigate = useNavigate();
// Estados
const [openNotif, setOpenNotif] = useState(false);
const [openProfile, setOpenProfile] = useState(false);
const [previewUrl, setPreviewUrl] = useState(AvatarForm);
const [darkMode, setDarkMode] = useState(false);
// Estado para dados do utilizador
const [userData, setUserData] = useState({
email: "Carregando...",
role: "",
lastSignIn: ""
});
const notifRef = useRef(null);
const profileRef = useRef(null);
const fileRef = useRef(null);
const isDoctor = location.pathname.startsWith("/doctor");
const userId = getUserId();
const extensions = ["png", "jpg", "jpeg", "gif"];
// --- EFEITOS ---
// 1. Dark Mode
useEffect(() => {
const saved = localStorage.getItem(LS_KEYS.dark) === "true";
setDarkMode(saved);
document.body.classList.toggle("dark-mode", saved);
}, []);
// 2. Fechar ao clicar fora
useEffect(() => {
function handleClickOutside(e) {
if (notifRef.current && !notifRef.current.contains(e.target)) setOpenNotif(false);
if (profileRef.current && !profileRef.current.contains(e.target)) setOpenProfile(false);
}
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
// 3. Carregar Avatar
useEffect(() => {
const loadAvatar = async () => {
if (!userId) return;
var requestOptions = {
headers: myHeaders,
method: 'GET',
redirect: 'follow'
};
const possibleNames = ['avatar', 'secretario', 'profile', 'user'];
for (const name of possibleNames) {
for (const ext of extensions) {
try {
const response = await fetch(`${supabaseUrl}/storage/v1/object/avatars/${userId}/${name}.${ext}`, requestOptions);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setPreviewUrl(imageUrl);
return;
}
} catch (error) { }
}
}
};
loadAvatar();
}, [userId]);
// 4. Determinar Nome Amigável da Role (Função auxiliar restaurada)
const getFriendlyRole = () => {
const role = getUserRole(); // Tenta pegar do localStorage/utils primeiro
if (role) {
switch (role) {
case "medico": return "Médico";
case "paciente": return "Paciente";
case "admin": return "Administrador";
case "secretaria": return "Secretária";
default: return role;
}
}
// Fallback baseado na URL se não tiver role salva
if (location.pathname.startsWith("/doctor")) return "Médico";
if (location.pathname.startsWith("/patientapp")) return "Paciente";
if (location.pathname.startsWith("/admin")) return "Administrador";
if (location.pathname.startsWith("/secretaria")) return "Secretária";
return "Usuário";
};
// 5. Buscar dados do utilizador e combinar com a Role Amigável
useEffect(() => {
const fetchUserDetails = async () => {
// Define a role inicial baseada na lógica local (mais confiável para exibição)
const friendlyRole = getFriendlyRole();
if (!userId) {
setUserData(prev => ({ ...prev, role: friendlyRole }));
return;
}
try {
const response = await fetch("https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/user", {
method: 'GET',
headers: {
'apikey': supabaseAK,
'Authorization': `Bearer ${getAccessToken()}`
}
});
if (response.ok) {
const data = await response.json();
const date = data.last_sign_in_at
? new Date(data.last_sign_in_at).toLocaleString('pt-PT', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
})
: "Primeiro acesso";
// Se a API retornar metadados com role, usamos, senão usamos a friendlyRole calculada
const apiRole = data.user_metadata?.role || friendlyRole;
setUserData({
email: data.email,
role: apiRole, // Aqui agora usamos a role tratada
lastSignIn: date
});
} else {
// Se a API falhar, garante que a role local seja mostrada
setUserData(prev => ({ ...prev, role: friendlyRole }));
}
} catch (error) {
console.error("Erro ao buscar detalhes do utilizador", error);
setUserData(prev => ({ ...prev, role: friendlyRole }));
}
};
fetchUserDetails();
}, [userId, location.pathname]); // Atualiza se mudar de rota também
// --- FUNÇÕES ---
const toggleDarkMode = () => {
const next = !darkMode;
setDarkMode(next);
localStorage.setItem(LS_KEYS.dark, String(next));
document.body.classList.toggle("dark-mode", next);
};
const handleLogout = async () => {
Swal.fire({
title: "Tem a certeza que deseja sair?",
text: "Precisará de fazer login novamente para aceder ao sistema.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#e63946",
cancelButtonColor: "#6c757d",
confirmButtonText: "Sair",
cancelButtonText: "Cancelar",
}).then(async (result) => {
if (result.isConfirmed) {
const success = await logoutUser();
if (success) {
clearUserInfo();
navigate("/login");
}
}
});
};
const handleAvatarUpload = () => {
setOpenProfile(false);
Swal.fire({
title: 'Alterar Foto de Perfil',
html: `
<div style="text-align: center;">
<div style="margin-bottom: 20px;">
<img id="preview-avatar" src="${previewUrl}" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 3px solid #ddd;" />
</div>
<input type="file" id="avatar-input" accept="image/*" style="padding: 10px; border: 1px solid #ddd; width: 100%;" />
</div>`,
showCancelButton: true,
confirmButtonText: 'Salvar',
preConfirm: () => {
const fileInput = document.getElementById('avatar-input');
const file = fileInput.files[0];
if (!file) { Swal.showValidationMessage('Selecione uma imagem'); return false; }
return file;
},
didOpen: () => {
const fileInput = document.getElementById('avatar-input');
const previewImg = document.getElementById('preview-avatar');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => previewImg.src = e.target.result;
reader.readAsDataURL(file);
}
});
}
}).then((result) => {
if (result.isConfirmed && result.value) {
uploadToSupabase(result.value);
}
});
};
const uploadToSupabase = async (file) => {
console.log("Upload simulado:", file.name);
};
const handleAccessibilitySettings = () => {
setOpenProfile(false);
let daltonismMode = localStorage.getItem(LS_KEYS.daltonism) === "true";
let fontScale = parseInt(localStorage.getItem(LS_KEYS.font) || "100", 10);
let leituraAtiva = false;
const applyFontScale = (next) => {
const clamped = Math.max(80, Math.min(180, next));
fontScale = clamped;
localStorage.setItem(LS_KEYS.font, String(clamped));
document.documentElement.style.fontSize = `${clamped}%`;
const fontSpan = document.getElementById('modal-font-size');
if (fontSpan) fontSpan.textContent = `${clamped}%`;
};
const toggleDaltonismMode = () => {
daltonismMode = !daltonismMode;
localStorage.setItem(LS_KEYS.daltonism, String(daltonismMode));
document.body.classList.toggle("daltonism-mode", daltonismMode);
};
let selectionChangeListener = null;
const lerTextoSelecionado = () => {
const texto = window.getSelection().toString().trim();
if (!texto) return;
window.speechSynthesis.cancel();
const fala = new SpeechSynthesisUtterance(texto);
fala.lang = "pt-PT";
fala.rate = 1;
window.speechSynthesis.speak(fala);
};
const toggleLeituraAtiva = () => {
leituraAtiva = !leituraAtiva;
const btn = document.getElementById('modal-toggle-leitura');
if (btn) {
btn.textContent = leituraAtiva ? "🟢 Leitura automática ativada" : "🔊 Ativar leitura automática";
btn.classList.toggle("active", leituraAtiva);
}
if (selectionChangeListener) {
document.removeEventListener("selectionchange", selectionChangeListener);
selectionChangeListener = null;
}
if (leituraAtiva) {
selectionChangeListener = () => {
const texto = window.getSelection().toString().trim();
if (texto.length > 1) lerTextoSelecionado();
};
document.addEventListener("selectionchange", selectionChangeListener);
} else {
window.speechSynthesis.cancel();
}
};
Swal.fire({
title: 'Configurações de Acessibilidade',
html: `
<style>
.acc-switch-large { position: relative; display: inline-block; width: 70px; height: 38px; vertical-align: middle; margin-right: 15px; }
.acc-switch-large input { opacity: 0; width: 0; height: 0; }
.acc-slider-large { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 38px; }
.acc-slider-large:before { position: absolute; content: ""; height: 30px; width: 30px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
input:checked + .acc-slider-large { background-color: #009efb; }
input:checked + .acc-slider-large:before { transform: translateX(32px); }
.acc-row-modal { display: flex; align-items: center; justify-content: flex-start; padding: 10px 0; }
.acc-label-modal { font-size: 16px; font-weight: 500; cursor: pointer; }
.acc-font-controls-modal button { padding: 8px 15px; margin: 0 5px; border: 1px solid #ddd; background: #f8f9fa; border-radius: 4px; cursor: pointer; font-weight: bold; }
.acc-font-controls-modal button:hover { background: #e2e6ea; }
.acc-btn-read { width: 100%; padding: 12px; border: 1px solid #009efb; background: white; color: #009efb; border-radius: 5px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.acc-btn-read:hover { background: #e3f2fd; }
</style>
<div id="accessibility-modal-content" style="text-align: left; padding: 0 10px;">
<div class="acc-row-modal">
<label class="acc-switch-large">
<input type="checkbox" id="daltonism-toggle" ${daltonismMode ? 'checked' : ''}/>
<span class="acc-slider-large"></span>
</label>
<span class="acc-label-modal" onclick="document.getElementById('daltonism-toggle').click()">Modo daltônico</span>
</div>
<hr style="margin: 15px 0; border-color: #eee;">
<div class="acc-row-modal" style="justify-content: space-between;">
<span class="acc-label-modal">Tamanho da fonte: <strong id="modal-font-size" style="color:#009efb">${fontScale}%</strong></span>
<div class="acc-font-controls-modal">
<button id="dec-font" title="Diminuir fonte">A</button>
<button id="reset-font" title="Resetar tamanho">Padrão</button>
<button id="inc-font" title="Aumentar fonte">A+</button>
</div>
</div>
<hr style="margin: 15px 0; border-color: #eee;">
<div style="margin-top: 15px;">
<button id="modal-toggle-leitura" class="acc-btn-read">🔊 Ativar leitura automática</button>
</div>
</div>
`,
showConfirmButton: false,
showCancelButton: true,
cancelButtonText: 'Fechar',
cancelButtonColor: '#6c757d',
width: '450px',
didOpen: () => {
const popup = Swal.getPopup();
if (!popup) return;
const daltonismToggle = popup.querySelector('#daltonism-toggle');
const decFontBtn = popup.querySelector('#dec-font');
const resetFontBtn = popup.querySelector('#reset-font');
const incFontBtn = popup.querySelector('#inc-font');
const toggleLeituraBtn = popup.querySelector('#modal-toggle-leitura');
if (daltonismToggle) daltonismToggle.addEventListener('change', toggleDaltonismMode);
if (decFontBtn) decFontBtn.addEventListener('click', () => applyFontScale(fontScale - 10));
if (resetFontBtn) resetFontBtn.addEventListener('click', () => applyFontScale(100));
if (incFontBtn) incFontBtn.addEventListener('click', () => applyFontScale(fontScale + 10));
if (toggleLeituraBtn) toggleLeituraBtn.addEventListener('click', toggleLeituraAtiva);
},
willClose: () => {
if (selectionChangeListener) document.removeEventListener("selectionchange", selectionChangeListener);
window.speechSynthesis.cancel();
}
});
};
return (
<div className="header">
<div className="header-left">
<Link to={isDoctor ? "/doctor" : "/admin"} className="logo">
<img src="/img/logo50.png" width="55" height="55" alt="logo" />{" "}
<span>MediConnect</span>
</Link>
</div>
<a id="mobile_btn" className="mobile_btn float-left" href="#sidebar" onClick={(e) => { e.preventDefault(); onMenuClick(); }}>
<i className="fa fa-bars"></i>
</a>
<ul className="nav user-menu float-right">
<li className="nav-item dm-container">
<button onClick={toggleDarkMode} className={`dm-button ${darkMode ? "dark" : "light"}`}>
{darkMode ? (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
)}
</button>
</li>
<li className="nav-item dropdown has-arrow" ref={profileRef}>
<div className="upload-img" onClick={() => setOpenProfile(!openProfile)} style={{ cursor: "pointer" }}>
<img alt="" src={previewUrl} style={{ marginTop: "5px", borderRadius: "50%", objectFit: "cover", width: "40px", height: "40px" }} />
</div>
<div className={`dropdown-menu${openProfile ? " show" : ""}`} style={{ minWidth: "260px" }}>
{/* --- CORREÇÃO MODO ESCURO ---
Adicionei estilos condicionais baseados no estado `darkMode`.
Fundo: #f8f9fa (claro) / #2c2c2c (escuro)
Texto: #333 (claro) / #e0e0e0 (escuro)
Bordas: #eaeaea (claro) / #444 (escuro)
*/}
<div style={{
padding: "15px",
backgroundColor: darkMode ? "#2c2c2c" : "#f8f9fa", // Corrigido
borderBottom: darkMode ? "1px solid #444" : "1px solid #eaeaea", // Corrigido
marginBottom: "10px",
transition: "background-color 0.3s, color 0.3s"
}}>
<div style={{
fontWeight: "bold",
fontSize: "16px",
color: darkMode ? "#fff" : "#333", // Corrigido
marginBottom: "5px",
textTransform: "capitalize"
}}>
{/* --- CORREÇÃO ROLE DO USUÁRIO --- */}
{userData.role}
</div>
<div style={{
fontSize: "13px",
color: darkMode ? "#bbb" : "#666", // Corrigido
marginBottom: "8px",
wordBreak: "break-all"
}}>
{userData.email}
</div>
<div style={{ fontSize: "11px", color: darkMode ? "#888" : "#999" }}>
<i className="fa fa-clock-o"></i> Login: {userData.lastSignIn}
</div>
</div>
<button className="dropdown-item" onClick={handleAvatarUpload}>
<i className="fa fa-camera"></i> Alterar Foto
</button>
<button className="dropdown-item" onClick={handleAccessibilitySettings}>
<i className="fa fa-cog"></i> Configurações
</button>
<div className="dropdown-divider"></div>
<button className="dropdown-item logout-btn" onClick={handleLogout}>
<i className="fa fa-sign-out"></i> Sair
</button>
</div>
</li>
</ul>
</div>
);
}
export default Navbar;

View File

@ -0,0 +1,120 @@
import { getUserRole } from "../../utils/userInfo";
import { Link, useLocation } from "react-router-dom";
import { useState } from "react";
import AccessibilityWidget from "../AccessibilityWidget.jsx";
import Chatbox from "../chat/Chatbox.jsx";
import Navbar from "./Navbar.jsx";
function Sidebar() {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const role = getUserRole();
// 2. Adicione a função para alternar o estado
const toggleSidebar = () => {
setSidebarOpen(!isSidebarOpen);
};
// 3. Crie a string de classe que será aplicada dinamicamente
const mainWrapperClass = isSidebarOpen ? 'main-wrapper sidebar-open' : 'main-wrapper';
// Função para verificar se a rota está ativa
const isActive = (path) => {
const currentPath = location.pathname;
// Verificação exata primeiro
if (currentPath === path) return true;
// Verificação de subrotas (ex: /admin/doctorlist/edit/123)
if (currentPath.startsWith(path + '/')) return true;
// Verificações específicas para páginas de edição/criação
if (path === `/${role}/doctorlist` && (
currentPath.includes(`/${role}/editdoctor/`) ||
currentPath.includes(`/${role}/doctorform`)
)) return true;
if (path === `/${role}/patientlist` && (
currentPath.includes(`/${role}/editpatient/`) ||
currentPath.includes(`/${role}/patientform`)
)) return true;
if (path === `/${role}/consultalist` && (
currentPath.includes(`/${role}/consultaform`) ||
currentPath.includes(`/${role}/editconsulta/`)
)) return true;
if (path === `/${role}/createuser` && (
currentPath.includes(`/${role}/createuser/`)
)) return true;
if (path === `/${role}/doctor-exceptions` && (
currentPath.includes(`/${role}/doctor-exceptions/`)
)) return true;
if (path === `/${role}/agendadoctor` && (
currentPath.includes(`/${role}/editdoctorschedule/`) ||
currentPath.includes(`/${role}/agendaform`)
)) return true;
if (path === `/${role}/laudolist` && (
currentPath.includes(`/${role}/laudoedit/`) ||
currentPath.includes(`/${role}/laudo`) ||
currentPath.includes(`/${role}/laudolist/`)
)) return true;
return false;
};
const permissoes = {
admin: ['dashboard', 'consultalist', 'laudolist', 'patientlist', 'doctorlist', 'agendadoctor', 'createuser', 'excecao'],
medico: ['consultalist', 'dashboard', 'patientlist', 'prontuariolist', 'laudolist', 'excecao', 'agendadoctor', 'doctorcalendar'],
secretaria: ['dashboard', 'agendadoctor', 'consultalist', 'patientlist', 'doctorlist',],
paciente: ['dashboard', 'medicosdisponiveis', 'consultalist', 'laudolist', 'agendarconsulta'],
};
function temPermissao(role, acao) {
return permissoes[role]?.includes(acao);
}
const menuItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'fa-bar-chart-o', path: 'dashboard' },
{ key: 'doctorlist', label: 'Médicos', icon: 'fa-user-md', path: 'doctorlist' },
{ key: 'patientlist', label: 'Pacientes', icon: 'fa-wheelchair', path: 'patientlist' },
{ key: 'calendar', label: 'Calendario', icon: 'fa-calendar', path: 'calendar' },
{ key: 'agendadoctor', label: 'Agenda Médica', icon: 'fa-clock-o', path: 'agendadoctor' },
{ key: 'consultalist', label: 'Consultas', icon: 'fa-stethoscope', path: 'consultalist' },
{ key: 'laudolist', label: 'Laudos', icon: 'fa-file-text-o', path: 'laudolist' },
{ key: 'createuser', label: 'Usuários', icon: 'fa-users', path: 'createuser' },
{ key: 'excecao', label: 'Exceções do Médico', icon: 'fa-calendar-times-o', path: 'excecao' },
{ key: 'medicosdisponiveis', label: 'Agendar Consultas', icon: 'fa fa-calendar-plus-o', path: 'medicosdisponiveis' },
{ key: 'doctorcalendar', label: 'Calendário', icon: 'fa fa-calendar', path: 'doctorcalendar' },
];
return (
<div>
<div className={mainWrapperClass}>
<Navbar onMenuClick={toggleSidebar} />
<div className="sidebar" id="sidebar">
<div className="sidebar-inner slimscroll">
<div id="sidebar-menu" className="sidebar-menu">
<ul>
<li className="menu-title"> Painel da {role}</li>
{/* ✅ CORRIGIR: Map correto com return */}
{menuItems
.filter(item => temPermissao(role, item.key))
.map(item => (
<li key={item.key} className={isActive(`/${role}/${item.path}`) ? 'active' : ''}>
<Link to={`/${role}/${item.path}`}>
<i className={`fa ${item.icon}`} /> <span>{item.label}</span>
</Link>
</li>
))
}
</ul>
</div>
</div>
<AccessibilityWidget />
<Chatbox />
</div>
</div>
</div >
);
}
export default Sidebar;

View File

@ -0,0 +1,665 @@
import "../../assets/css/index.css"
import { Link } from "react-router-dom";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { getAccessToken } from "../../utils/auth.js";
import { getUserRole } from "../../utils/userInfo.js";
import { getDoctorId } from "../../utils/userInfo.js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Componente para o dropdown portal
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
useLayoutEffect(() => {
if (!isOpen || !anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
if (left < 0) left = scrollX + 4;
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
if (
menuRef.current &&
!menuRef.current.contains(e.target) &&
anchorEl &&
!anchorEl.contains(e.target)
) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className}
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function AgendaDoctor() {
const [agenda, setAgenda] = useState([]);
const [medicos, setMedicos] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
const [search, setSearch] = useState("");
const [dayFilter, setDayFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [deleteId, setDeleteId] = useState(null);
const [editId, setEditId] = useState(null);
const [editData, setEditData] = useState({
doctor_id: "",
weekday: "",
start_time: "",
end_time: "",
slot_minutes: 30,
appointment_type: "",
active: true,
});
const anchorRefs = useRef({});
const role = getUserRole();
const tokenUsuario = getAccessToken();
const requestOptions = {
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
redirect: "follow",
};
// Fetch agenda
useEffect(() => {
if (getUserRole() === 'medico') {
fetch(
`${supabaseUrl}/rest/v1/doctor_availability?doctor_id=eq.${getDoctorId()}`,
requestOptions
)
.then((res) => res.json())
.then((result) => setAgenda(Array.isArray(result) ? result : []))
.catch((err) => console.log(err));
} else {
fetch(
`${supabaseUrl}/rest/v1/doctor_availability`,
requestOptions
)
.then((res) => res.json())
.then((result) => setAgenda(Array.isArray(result) ? result : []))
.catch((err) => console.log(err));
}
}, []);
// Fetch médicos
useEffect(() => {
fetch("https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/doctors", requestOptions)
.then((res) => res.json())
.then((result) => setMedicos(Array.isArray(result) ? result : []))
.catch((err) => console.log(err));
}, []);
const getDoctorName = (id) => {
if (!id) return "";
const medico = medicos.find((m) => m.id === id);
return medico ? medico.full_name || medico.name || "" : id;
};
// DELETE
const handleDelete = (id) => setDeleteId(id);
const confirmDelete = () => {
if (!deleteId) return;
fetch(
`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/doctor_availability?id=eq.${deleteId}`,
{
method: "DELETE",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
)
.then((res) => {
if (!res.ok) throw new Error("Erro ao deletar a agenda");
setAgenda((prev) => prev.filter((a) => a.id !== deleteId));
setDeleteId(null);
})
.catch((err) => console.log(err));
};
// EDIT
const handleEditClick = (id) => {
const agendaItem = agenda.find((a) => a.id === id);
if (!agendaItem) return;
setEditData({
doctor_id: agendaItem.doctor_id || "",
weekday: agendaItem.weekday || "",
start_time: agendaItem.start_time || "",
end_time: agendaItem.end_time || "",
slot_minutes: agendaItem.slot_minutes || 30,
appointment_type: agendaItem.appointment_type || "",
active: agendaItem.active ?? true,
});
setEditId(id);
setOpenDropdown(null);
};
const handleEditChange = (e) => {
const { name, value, type, checked } = e.target;
setEditData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const submitEdit = () => {
if (!editId) return;
if (!editData.doctor_id) {
alert("Selecione um médico válido.");
return;
}
if (!editData.weekday || !editData.start_time || !editData.end_time || !editData.appointment_type) {
alert("Preencha todos os campos obrigatórios.");
return;
}
fetch(
`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/doctor_availability?id=eq.${editId}`,
{
method: "PATCH",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
"Content-Type": "application/json",
"Prefer": "return=representation", // ESSENCIAL
},
body: JSON.stringify(editData),
}
)
.then((res) => {
if (!res.ok) throw new Error("Erro ao salvar alterações");
return res.json();
})
.then((updated) => {
setAgenda((prev) =>
prev.map((a) => (a.id === editId ? { ...a, ...updated[0] } : a))
);
setEditId(null);
})
.catch((err) => {
console.error(err);
alert("Erro ao salvar alterações. Verifique os campos e tente novamente.");
});
};
const filteredAgenda = agenda.filter((a) => {
if (!a) return false;
const q = search.toLowerCase();
// Filtro por texto (nome do médico, dia, tipo)
const matchesText = (
(getDoctorName(a.doctor_id) || "").toLowerCase().includes(q) ||
(a.weekday || "").toLowerCase().includes(q) ||
(a.appointment_type || "").toLowerCase().includes(q)
);
// Filtro por dia da semana
const matchesDay = !dayFilter || a.weekday === dayFilter;
// Filtro por tipo de consulta
const matchesType = !typeFilter || a.appointment_type === typeFilter;
return matchesText && matchesDay && matchesType;
});
// Paginação
const [itemsPerPage1, setItemsPerPage1] = useState(15);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastAgenda = currentPage1 * itemsPerPage1;
const indexOfFirstAgenda = indexOfLastAgenda - itemsPerPage1;
const currentAgenda = filteredAgenda.slice(indexOfFirstAgenda, indexOfLastAgenda);
const totalPages1 = Math.ceil(filteredAgenda.length / itemsPerPage1);
// Reset da paginação quando filtros mudam
useEffect(() => {
setCurrentPage1(1);
}, [search, dayFilter, typeFilter]);
const permissoes = {
admin: ['nome'],
secretaria: ['nome'],
medico: ['']
};
const pode = (acao) => permissoes[role]?.includes(acao);
return (
<div className="page-wrapper">
<div className="content">
{/* Header com título e botão */}
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Agenda Médica</h4>
<Link to={`/${role}/agendaform`} className="btn btn-primary btn-rounded">
<i className="fa fa-plus"></i> Adicionar agenda
</Link>
</div>
{/* Todos os filtros em uma única linha */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.5rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar agenda"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: "300px", maxWidth: "450px", }}
/>
{/* Filtro por dia da semana */}
<select
className="form-control form-control-sm"
style={{ minWidth: "100px", maxWidth: "140px" }}
value={dayFilter}
onChange={(e) => setDayFilter(e.target.value)}
>
<option value="">Todos os dias</option>
<option value="monday">Segunda-feira</option>
<option value="tuesday">Terça-feira</option>
<option value="wednesday">Quarta-feira</option>
<option value="thursday">Quinta-feira</option>
<option value="friday">Sexta-feira</option>
<option value="saturday">Sábado</option>
<option value="sunday">Domingo</option>
</select>
{/* Filtro por tipo de consulta */}
<select
className="form-control form-control-sm"
style={{ minWidth: "100px", maxWidth: "140px" }}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">Todos os tipos</option>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
{/* Tabela */}
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
{pode('nome') && <th>Nome</th>}
<th>Dias disponíveis</th>
<th>Horário disponível</th>
<th>Duração (min)</th>
<th>Tipo</th>
<th>Status</th>
<th className="text-center">Ação</th>
</tr>
</thead>
<tbody>
{currentAgenda.length > 0 ? (
currentAgenda.map((a) => (
<tr key={a.id}>
{pode('nome') && <td>{getDoctorName(a.doctor_id)}</td>}
<td>
<span className="custom-badge status-blue" style={{ minWidth: '90px', display: 'inline-block', textAlign: 'center' }}>
<i className="fa fa-calendar" style={{ marginRight: '6px' }}></i>
{a.weekday === 'monday' ? 'Segunda' :
a.weekday === 'tuesday' ? 'Terça' :
a.weekday === 'wednesday' ? 'Quarta' :
a.weekday === 'thursday' ? 'Quinta' :
a.weekday === 'friday' ? 'Sexta' :
a.weekday === 'saturday' ? 'Sábado' :
a.weekday === 'sunday' ? 'Domingo' :
a.weekday}
</span>
</td>
<td>
{a.start_time || ""} ás {a.end_time || ""}
</td>
<td>{a.slot_minutes || 30}</td>
<td>
<span
className={`custom-badge ${
a.appointment_type === 'presencial' ? 'status-green' :
a.appointment_type === 'telemedicina' ? 'status-blue' :
'status-gray'
}`}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px', display: 'inline-block', textAlign: 'center' }}
>
{a.appointment_type === 'presencial' ? (
<>
<i className="fa fa-hospital-o" style={{ marginRight: '6px' }}></i>
Presencial
</>
) : a.appointment_type === 'telemedicina' ? (
<>
<i className="fa fa-video-camera" style={{ marginRight: '6px' }}></i>
Telemedicina
</>
) : (
a.appointment_type
)}
</span>
</td>
<td>
<span
className={`custom-badge ${
a.active ? "status-green" : "status-red"
}`}
style={{ minWidth: '80px', display: 'inline-block', textAlign: 'center' }}
>
{a.active ? (
<>
<i className="fa fa-check-circle" style={{ marginRight: '6px' }}></i>
Ativo
</>
) : (
<>
<i className="fa fa-times-circle" style={{ marginRight: '6px' }}></i>
Inativo
</>
)}
</span>
</td>
<td className="text-center">
<div className="action-buttons-container">
<button
type="button"
className="action-btn action-btn-edit"
onClick={() => handleEditClick(a.id)}
title="Editar agenda"
>
<span className="fa fa-pencil m-r-5"></span>
</button>
<button
type="button"
className="action-btn action-btn-delete"
onClick={() => handleDelete(a.id)}
title="Excluir agenda"
>
<span className="fa fa-trash-o"></span>
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="text-center text-muted">
Nenhuma agenda encontrada
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Paginação */}
<div className="d-flex flex-wrap align-items-center mt-3">
<div className="me-3 text-muted" style={{ minWidth: '140px', fontSize: '0.98em', paddingRight: '3%' }}>
Total encontrados: <b>{filteredAgenda.length}</b>
</div>
<div style={{ minWidth: '140px' }}>
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px", display: 'inline-block' }}
value={itemsPerPage1}
onChange={e => {
setItemsPerPage1(Number(e.target.value));
setCurrentPage1(1);
}}
title="Itens por página"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
<option value={30}>30 por página</option>
</select>
</div>
</div>
<div className="w-100 d-flex justify-content-center mt-2">
<nav>
<ul className="pagination mb-0 justify-content-center">
{/* Primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"}
</button>
</li>
{/* Página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.max(prev - 1, 1))}>
&lt;
</button>
</li>
{/* Número da página atual */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.min(prev + 1, totalPages1))}>
&gt;
</button>
</li>
{/* Última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"}
</button>
</li>
</ul>
</nav>
</div>
{/* Modal de Delete */}
{deleteId && (
<div className="modal fade show" style={{ display: "block" }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-body text-center">
<img src="/img/sent.png" alt="" width="50" height="46" />
<h3>Tem certeza que deseja deletar esta agenda?</h3>
<div className="mt-3">
<button
className="btn btn-white me-2"
onClick={() => setDeleteId(null)}
>
Fechar
</button>
<button className="btn btn-danger" onClick={confirmDelete}>
Deletar
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Modal de Edit */}
{editId && (
<div className="modal fade show" style={{ display: "block" }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Editar Disponibilidade</h5>
<button className="btn-close" onClick={() => setEditId(null)}></button>
</div>
<div className="modal-body">
<div className="mb-2">
<label>Médico</label>
<select
className="form-control"
name="doctor_id"
value={editData.doctor_id}
onChange={handleEditChange}
required
>
<option value="">Selecione o médico</option>
{medicos.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name || m.name}
</option>
))}
</select>
</div>
<div className="mb-2">
<label>Dia da semana</label>
<select
className="form-control"
name="weekday"
value={editData.weekday}
onChange={handleEditChange}
required
>
<option value="">Selecione o dia</option>
<option value="monday">Monday</option>
<option value="tuesday">Tuesday</option>
<option value="wednesday">Wednesday</option>
<option value="thursday">Thursday</option>
<option value="friday">Friday</option>
<option value="saturday">Saturday</option>
<option value="sunday">Sunday</option>
</select>
</div>
<div className="mb-2">
<label>Início</label>
<input
type="time"
className="form-control"
name="start_time"
value={editData.start_time}
onChange={handleEditChange}
required
/>
</div>
<div className="mb-2">
<label>Fim</label>
<input
type="time"
className="form-control"
name="end_time"
value={editData.end_time}
onChange={handleEditChange}
required
/>
</div>
<div className="mb-2">
<label>Duração (min)</label>
<input
type="number"
className="form-control"
name="slot_minutes"
value={editData.slot_minutes}
onChange={handleEditChange}
min={1}
required
/>
</div>
<div className="mb-2">
<label>Tipo de consulta</label>
<select
className="form-control"
name="appointment_type"
value={editData.appointment_type}
onChange={handleEditChange}
required
>
<option value="">Selecione o tipo</option>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
<div className="form-check mb-2">
<input
type="checkbox"
className="form-check-input"
name="active"
checked={editData.active}
onChange={handleEditChange}
/>
<label className="form-check-label">Ativo</label>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setEditId(null)}>
Fechar
</button>
<button className="btn btn-primary" onClick={submitEdit}>
Salvar
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default AgendaDoctor;

View File

@ -0,0 +1,813 @@
import "../../assets/css/index.css"
import { Link } from "react-router-dom";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { getAccessToken} from "../../utils/auth.js";
import Swal from "sweetalert2";
import { useResponsive } from '../../utils/useResponsive.js';
import { useNavigate } from "react-router-dom";
import { getUserRole } from "../../utils/userInfo.js";
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
// Posiciona o menu após renderar (medir tamanho do menu)
useLayoutEffect(() => {
if (!isOpen) return;
if (!anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// tenta alinhar à direita do botão (como dropdown-menu-right)
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
// evita sair da esquerda da tela
if (left < 0) left = scrollX + 4;
// se extrapolar bottom, abre para cima
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
// fecha ao clicar fora / ao rolar
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
const menu = menuRef.current;
if (menu && !menu.contains(e.target) && anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
// captura scroll em qualquer elemento (true)
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className} // mantém as classes que você já usa no CSS
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function ConsultaList() {
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({});
const [consulta, setConsultas] = useState([]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const tokenUsuario = getAccessToken()
const [pacientesMap, setPacientesMap] = useState({});
const [medicosMap, setMedicosMap] = useState({});
const [period, setPeriod] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const headers = {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
"Content-Type": "application/json",
};
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/appointments`, requestOptions)
.then(response => response.json())
.then(result => setConsultas(Array.isArray(result) ? result : []))
.catch(error => console.log('error', error));
}, [])
const handleDelete = async (id) => {
if (getUserRole() === 'paciente') {
Swal.fire("Ação não permitida", "Pacientes não podem excluir consultas. Por favor, entre em contato com a secretaria.", "warning");
return;
}
const confirm = await Swal.fire({
title: "Tem certeza?",
text: "Deseja realmente excluir esta consulta? Essa ação não poderá ser desfeita.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#e63946",
cancelButtonColor: "#6c757d",
confirmButtonText: "Excluir!",
cancelButtonText: "Cancelar",
});
if (!confirm.isConfirmed) return;
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments?id=eq.${id}`,
{
method: "DELETE",
headers: myHeaders,
}
);
console.log("Resposta do delete:", response);
if (response.ok) {
setConsultas((prev) => prev.filter((c) => c.id !== id));
setOpenDropdown(null);
Swal.fire({
title: "Excluída!",
text: "A consulta foi removida com sucesso.",
icon: "success",
timer: 2000,
showConfirmButton: false,
});
} else {
Swal.fire("Erro", "Falha ao excluir a consulta. Tente novamente.", "error");
}
} catch (error) {
console.error("Erro ao deletar:", error);
Swal.fire("Erro", "Não foi possível conectar ao servidor.", "error");
}
};
const filteredConsultas = consulta.filter(p => {
if (!p) return false;
const nome = (pacientesMap[p.patient_id] || "").toLowerCase();
const médicoNome = (medicosMap[p.doctor_id] || "").toLowerCase();
const cpf = (p.cpf || "").toLowerCase();
const email = (p.email || "").toLowerCase();
const q = search.toLowerCase();
// Filtro por texto (nome, cpf, email)
const matchesText = nome.includes(q) || cpf.includes(q) || email.includes(q) || médicoNome.includes(q);
// Filtro por status
const matchesStatus = !statusFilter || p.status === statusFilter;
// Filtro por tipo de consulta
const matchesType = !typeFilter || p.appointment_type === typeFilter;
let dateMatch = true;
if (p.scheduled_at) {
const consultaDate = new Date(p.scheduled_at);
const today = new Date();
// Filtros por período rápido
if (period === "today") {
const todayStr = today.toDateString();
dateMatch = consultaDate.toDateString() === todayStr;
} else if (period === "week") {
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - today.getDay());
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
dateMatch = consultaDate >= startOfWeek && consultaDate <= endOfWeek;
} else if (period === "month") {
dateMatch = consultaDate.getMonth() === today.getMonth() &&
consultaDate.getFullYear() === today.getFullYear();
}
// Filtros por data específica
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999); // Inclui o dia inteiro
dateMatch = dateMatch && consultaDate >= start && consultaDate <= end;
} else if (startDate) {
const start = new Date(startDate);
dateMatch = dateMatch && consultaDate >= start;
} else if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateMatch = dateMatch && consultaDate <= end;
}
}
return matchesText && matchesStatus && matchesType && dateMatch;
}).sort((a, b) => {
// Priorizar consultas "requested" (solicitadas) primeiro
if (a.status === 'requested' && b.status !== 'requested') {
return -1; // 'a' vem antes de 'b'
}
if (b.status === 'requested' && a.status !== 'requested') {
return 1; // 'b' vem antes de 'a'
}
// Se ambos têm o mesmo status de prioridade, ordena por data (mais recente primeiro)
const dateA = new Date(a.scheduled_at || 0);
const dateB = new Date(b.scheduled_at || 0);
return dateB - dateA;
});
const [itemsPerPage1, setItemsPerPage1] = useState(15);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastPatient = currentPage1 * itemsPerPage1;
const indexOfFirstPatient = indexOfLastPatient - itemsPerPage1;
const currentConsultas = filteredConsultas.slice(indexOfFirstPatient, indexOfLastPatient);
const totalPages1 = Math.ceil(filteredConsultas.length / itemsPerPage1);
useEffect(() => {
setCurrentPage1(1);
}, [search, statusFilter, typeFilter, period, startDate, endDate]);
// Função para definir períodos e limpar datas
const handlePeriodChange = (newPeriod) => {
// Se clicar no mesmo período, limpa o filtro
if (period === newPeriod) {
setPeriod("");
} else {
setPeriod(newPeriod);
}
// Sempre limpa as datas específicas
setStartDate("");
setEndDate("");
};
useEffect(() => {
if (!consulta || consulta.length === 0) return;
const buscarPacientes = async () => {
try {
// Pega IDs únicos de pacientes
const idsUnicos = [...new Set(consulta.map((c) => c.patient_id))];
// Faz apenas 1 fetch por paciente
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
{
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await res.json();
return { id, full_name: data[0]?.full_name || "Nome não encontrado" };
} catch (err) {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setPacientesMap(map);
} catch (err) {
console.error("Erro ao buscar pacientes:", err);
}
};
buscarPacientes();
}, [consulta]);
useEffect(() => {
if (!Array.isArray(consulta) || consulta.length === 0) return;
const buscarMedicos = async () => {
try {
const idsUnicos = [...new Set(consulta.map((c) => c.doctor_id).filter(Boolean))];
if (idsUnicos.length === 0) return;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenUsuario}`,
apikey: supabaseAK,
};
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`, {
method: "GET",
headers,
});
if (!res.ok) return { id, full_name: "Nome não encontrado" };
const data = await res.json();
return { id, full_name: data?.[0]?.full_name || "Nome não encontrado" };
} catch {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setMedicosMap(map);
} catch (err) {
console.error("Erro ao buscar nomes dos médicos:", err);
}
};
buscarMedicos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [consulta]);
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
try {
// Extrai data e hora diretamente da string ISO sem conversão de timezone
const [datePart, timePart] = dateString.split('T');
const [year, month, day] = datePart.split('-');
const [hour, minute] = timePart.split(':');
return `${day}/${month}/${year} ${hour}:${minute}`;
} catch {
return dateString;
}
};
const handleConfirm = async (id) => {
if (getUserRole() === 'paciente') {
Swal.fire("Ação não permitida", "Pacientes não podem confirmar consultas diretamente. Por favor, entre em contato com a secretaria.", "warning");
return;
}
const confirm = await Swal.fire({
title: "Confirmar consulta?",
text: "Esta ação irá confirmar a consulta.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#4caf50",
cancelButtonColor: "#6c757d",
confirmButtonText: "Confirmar consulta",
cancelButtonText: "Voltar",
});
if (!confirm.isConfirmed) return;
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments?id=eq.${id}`,
{
method: "PATCH",
headers: {
...headers,
"Prefer": "return=minimal"
},
body: JSON.stringify({ status: "confirmed" })
}
);
if (response.ok) {
// Atualiza o estado local
setConsultas((prev) =>
prev.map((c) =>
c.id === id ? { ...c, status: "confirmed" } : c
)
);
Swal.fire({
title: "Confirmado!",
text: "Consulta confirmada com sucesso.",
icon: "success",
timer: 2000,
showConfirmButton: false,
});
} else {
throw new Error('Falha na confirmação');
}
} catch (error) {
console.error("Erro ao confirmar:", error);
Swal.fire("Erro", "Não foi possível confirmar a consulta.", "error");
}
};
const handleCancel = async (id) => {
if (getUserRole() === 'paciente') {
Swal.fire("Ação não permitida", "Pacientes não podem cancelar consultas diretamente. Por favor, entre em contato com a secretaria.", "warning");
return;
}
const confirm = await Swal.fire({
title: "Cancelar consulta?",
text: "Esta ação irá cancelar a consulta.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#e63946",
cancelButtonColor: "#6c757d",
confirmButtonText: "Cancelar consulta",
cancelButtonText: "Voltar",
});
if (!confirm.isConfirmed) return;
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments?id=eq.${id}`,
{
method: "PATCH",
headers: {
...headers,
},
body: JSON.stringify({ status: "cancelled" })
}
);
if (response.ok) {
setConsultas((prev) =>
prev.map((c) =>
c.id === id ? { ...c, status: "cancelled" } : c
)
);
Swal.fire({
title: "Cancelado!",
text: "Consulta cancelada com sucesso.",
icon: "success",
timer: 2000,
showConfirmButton: false,
});
} else {
throw new Error('Falha no cancelamento');
}
} catch (error) {
console.error("Erro ao cancelar:", error);
Swal.fire("Erro", "Não foi possível cancelar a consulta.", "error");
}
};
const navigate = useNavigate();
const role = getUserRole();
const permissoes = {
admin: ['editconsulta', 'deletarconsulta', 'consultaform', 'viewactionconsultas' , 'nomepaciente'],
medico: ['editconsulta', 'deletarconsulta', 'consultaform', 'viewactionconsultas', 'nomepaciente'],
secretaria: ['editconsulta', 'deletarconsulta', 'consultaform', 'viewactionconsultas', 'nomepaciente'],
paciente: ['']
};
const pode = (acao) => permissoes[role]?.includes(acao);
function hasAnyAction(c) {
return (
pode('editconsulta') ||
pode('deletarconsulta') ||
(c.status === 'confirmed' && pode('viewactionconsultas')) ||
(c.appointment_type === 'telemedicina' && c.status === 'confirmed') ||
(c.status === 'requested' && pode('viewactionconsultas'))
);
}
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-12">
{pode('consultaform') && (
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Lista de consultas</h4>
<Link to={`/${role}/consultaform`} className="btn btn-primary btn-rounded" >
<i className="fa fa-plus"></i> Adicionar consulta
</Link>
</div>
)}
{/* Todos os filtros em uma única linha */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.30rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar consulta"
style={{ minWidth: "300px", maxWidth: "450px", }}
onChange={(e) => setSearch(e.target.value)}
/>
{/* Filtro de status */}
<select
className="form-control form-control-sm"
style={{ minWidth: "80px", maxWidth: "125px", }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">Status</option>
<option value="requested">Solicitado</option>
<option value="confirmed">Confirmado</option>
<option value="completed">Concluído</option>
<option value="cancelled">Cancelado</option>
</select>
{/* Filtro De */}
<div className="d-flex align-items-center" style={{ gap: "0.25rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>De:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={startDate}
onChange={e => {
setStartDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Filtro Até */}
<div className="d-flex align-items-center" style={{ gap: "0.25rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>Até:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={endDate}
onChange={e => {
setEndDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Botões rápidos */}
<button
className={`btn btn-sm ${period === "today" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "60px", padding: "4px 8px", fontSize: "0.8rem" }}
onClick={() => handlePeriodChange("today")}
>
Hoje
</button>
<button
className={`btn btn-sm ${period === "week" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "70px", padding: "4px 8px", fontSize: "0.8rem" }}
onClick={() => handlePeriodChange("week")}
>
Semana
</button>
<button
className={`btn btn-sm ${period === "month" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "60px", padding: "4px 8px", fontSize: "0.8rem" }}
onClick={() => handlePeriodChange("month")}
>
Mês
</button>
</div>
</div>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-striped custom-table">
<thead>
<tr>
<th className="text-center">Pedido</th>
{pode('nomepaciente') && <th className="text-center">Nome do Paciente</th>}
<th className="text-center">Nome do Médico</th>
<th className="text-center">Agendado</th>
<th className="text-center">Duração</th>
<th className="text-center">Modo</th>
<th className="text-center">Status</th>
{currentConsultas.some(hasAnyAction) && <th className="text-center">Ação</th>}
</tr>
</thead>
<tbody>
{currentConsultas.length > 0 ? (
currentConsultas.map((c) => (
<tr key={c.id}>
<td className="text-center">{c.order_number}</td>
{pode('nomepaciente') && <td className="text-center">{pacientesMap[c.patient_id] || "Carregando..."}</td>}
<td className="text-center">{medicosMap[c.doctor_id] || "Carregando..."}</td>
<td className="text-center">{formatDate(c.scheduled_at)}</td>
<td className="text-center">{c.duration_minutes} min</td>
<td className="text-center">
<span
className={`custom-badge ${
c.appointment_type === 'presencial' ? 'status-green' :
c.appointment_type === 'telemedicina' ? 'status-blue' :
'status-gray'
}`}
style={{ minWidth: '110px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
{c.appointment_type === 'presencial' ? (
<>
<i className="fa fa-hospital-o" style={{ marginRight: '6px' }}></i>
Presencial
</>
) : c.appointment_type === 'telemedicina' ? (
<>
<i className="fa fa-video-camera" style={{ marginRight: '6px' }}></i>
Telemedicina
</>
) : (
c.appointment_type
)}
</span>
</td>
<td className="text-center">
<span
className={`custom-badge ${
c.status === 'requested' ? 'status-purple' :
c.status === 'confirmed' ? 'status-blue' :
c.status === 'completed' ? 'status-green' :
c.status === 'cancelled' ? 'status-red' :
'status-gray'
}`}
style={{ minWidth: '120px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
{c.status === 'requested' ? (
<>
<i className="fa fa-clock-o" style={{ marginRight: '6px' }}></i>
Solicitado
</>
) : c.status === 'confirmed' ? (
<>
<i className="fa fa-check-circle" style={{ marginRight: '6px' }}></i>
Confirmado
</>
) : c.status === 'completed' ? (
<>
<i className="fa fa-check" style={{ marginRight: '6px' }}></i>
Concluído
</>
) : c.status === 'cancelled' ? (
<>
<i className="fa fa-times-circle" style={{ marginRight: '6px' }}></i>
Cancelado
</>
) : (
<>
<i className="fa fa-question-circle" style={{ marginRight: '6px' }}></i>
{c.status}
</>
)}
</span>
</td>
{currentConsultas.some(hasAnyAction) && (
hasAnyAction(c) ? (
<td className="text-right">
<div className="action-buttons-container">
{c.appointment_type !== 'telemedicina' && c.status === 'confirmed' && (
<button
type="button"
className="btn btn-success btn-sm"
onClick={() => navigate(`/${role}/laudoconsulta`, {state: { consultaId: c.id, pacienteId: c.patient_id}})}
title="Atender consulta"
style={{ minWidth: 120, fontWeight: 600 }}
>
Atender
</button>
)}
{c.appointment_type === 'telemedicina' && c.status === 'confirmed' && (
<button
type="button"
className="btn btn-success btn-sm"
onClick={() => navigate(`/call/${c.id}`)}
title="Atender consulta"
style={{ minWidth: 120, fontWeight: 600 }}
>
Atender
</button>
)}
{pode('editconsulta') && (
<button
type="button"
className="action-btn action-btn-edit"
onClick={() => navigate(`/${role}/editconsulta/${c.id}`)}
title="Editar consulta"
>
<span className="fa fa-pencil m-r-5"></span>
</button>
)}
{c.status === 'requested' && pode('viewactionconsultas') && (
<>
<button
type="button"
className="action-btn action-btn-edit"
onClick={() => handleConfirm(c.id)}
title="Confirmar consulta"
>
<span className="fa fa-check"></span>
</button>
<button
type="button"
className="action-btn action-btn-delete"
onClick={() => handleCancel(c.id)}
title="Cancelar consulta"
>
<span className="fa fa-times"></span>
</button>
</>
)}
</div>
</td>
) : (
<td className="text-center">-</td>
)
)}
</tr>
))
) : (
<tr>
<td colSpan="7" className="text-center text-muted">
Nenhuma consulta encontrada.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="d-flex flex-wrap align-items-center mt-3">
<div className="me-3 text-muted" style={{ minWidth: '140px', fontSize: '0.98em', paddingRight: '3%' }}>
Total encontrados: <b>{filteredConsultas.length}</b>
</div>
<div style={{ minWidth: '140px' }}>
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px", display: 'inline-block' }}
value={itemsPerPage1}
onChange={e => {
setItemsPerPage1(Number(e.target.value));
setCurrentPage1(1);
}}
title="Itens por página"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
<option value={30}>30 por página</option>
</select>
</div>
</div>
<div className="w-100 d-flex justify-content-center mt-2">
<nav>
<ul className="pagination mb-0 justify-content-center">
{/* Primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"}
</button>
</li>
{/* Página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.max(prev - 1, 1))}>
&lt;
</button>
</li>
{/* Número da página atual */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.min(prev + 1, totalPages1))}>
&gt;
</button>
</li>
{/* Última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"}
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default ConsultaList;

View File

@ -0,0 +1,405 @@
import "../../assets/css/index.css";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import supabase from "../../Supabase.js";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
import { getUserRole } from "../../utils/userInfo.js";
const AvatarForm = "/img/AvatarForm.jpg";
function DoctorList() {
const [search, setSearch] = useState("");
const [specialtyFilter, setSpecialtyFilter] = useState(""); // Filtro por especialidade
const [doctors, setDoctors] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
const tokenUsuario = getAccessToken()
const role = getUserRole();
var myHeaders = new Headers();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
myHeaders.append(
"apikey",
supabaseAK
);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: "GET",
headers: myHeaders,
redirect: "follow",
};
// buscar médicos
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/doctors`, requestOptions)
.then((response) => response.json())
.then((result) => setDoctors(Array.isArray(result) ? result : []))
.catch((error) => console.log("error", error));
}, []);
const handleDelete = async (id) => {
Swal.fire({
title: "Tem certeza?",
text: "Tem certeza que deseja excluir este registro?",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Sim, excluir",
cancelButtonText: "Cancelar"
}).then(async (result) => {
if (result.isConfirmed) {
try {
const tokenUsuario = getAccessToken(); // pega o token do usuário (mesmo que usa no form)
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
const response = await fetch(
`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`,
{
method: "DELETE",
headers: myHeaders,
}
);
if (!response.ok) {
const err = await response.json();
console.error("Erro ao deletar médico:", err);
Swal.fire("Erro!", err.message || "Não foi possível excluir o registro.", "error");
return;
}
// Atualiza a lista local
setDoctors((prev) => prev.filter((doc) => doc.id !== id));
Swal.fire("Excluído!", "O registro foi removido com sucesso.", "success");
} catch (error) {
console.error("Erro inesperado:", error);
Swal.fire("Erro!", "Algo deu errado ao excluir.", "error");
}
}
});
};
const handleViewDetails = async (id) => {
try {
const tokenUsuario = getAccessToken();
const response = await fetch(
`${supabaseUrl}/rest/v1/doctors?id=eq.${id}`,
{
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await response.json();
const doctor = data[0];
if (!doctor) {
Swal.fire("Erro", "Não foi possível carregar os detalhes do médico.", "error");
return;
}
Swal.fire({
width: "800px",
showConfirmButton: true,
confirmButtonText: "Fechar",
confirmButtonColor: "#4dabf7",
background: document.body.classList.contains("dark-mode") ? "#1e1e2f" : "#fff",
color: document.body.classList.contains("dark-mode") ? "#f5f5f5" : "#000",
html: `
<div style="text-align:left;">
<!-- Cabeçalho -->
<div style="
display:flex;
justify-content:space-between;
align-items:center;
border-bottom:1px solid rgba(0,0,0,0.1);
margin-bottom:15px;
padding-bottom:5px;
">
<h5 style="margin:0;">Perfil Médico</h5>
<button id="btn-close-modal" style="
background:none;
border:none;
font-size:22px;
cursor:pointer;
color:#999;
">&times;</button>
</div>
<!-- Foto e Nome -->
<div style="text-align:center; margin-bottom:20px;">
<img
src="${doctor.foto || AvatarForm}"
alt="${doctor.full_name}"
style="
width:120px;
height:120px;
border-radius:50%;
object-fit:cover;
border:3px solid #4dabf7;
box-shadow:0 4px 8px rgba(0,0,0,0.1);
"
onerror="this.src='${AvatarForm}'"
/>
<h5 style="margin-top:10px;">${doctor.full_name}</h5>
<p class="text-muted">${doctor.specialty || "Especialidade não informada"}</p>
</div>
<!-- Informações pessoais -->
<div style="display:flex; justify-content:space-between; gap:20px;">
<div style="width:48%;">
<p><strong>Telefone:</strong> ${doctor.phone_mobile || "—"}</p>
<p><strong>Email:</strong> ${doctor.email || "—"}</p>
<p><strong>Data de nascimento:</strong> ${doctor.birth_date || "—"}</p>
<p><strong>Sexo:</strong> ${doctor.gender || "—"}</p>
</div>
<div style="width:48%;">
<p><strong>Região:</strong> ${doctor.city || "—"}, ${doctor.state || "—"}, Brasil</p>
<p><strong>CRM:</strong> ${doctor.crm || "—"}</p>
<p><strong>Especialidade:</strong> ${doctor.specialty || "—"}</p>
<p><strong>Experiência:</strong> ${doctor.experience_years || "—"} anos</p>
</div>
</div>
<!-- Biografia -->
<div style="margin-top:25px;">
<h5>Biografia</h5>
<p style="text-align:justify;">
${doctor.biografia || "Este médico ainda não possui biografia cadastrada."}
</p>
</div>
</div>
`,
didOpen: () => {
document
.getElementById("btn-close-modal")
?.addEventListener("click", () => Swal.close());
},
});
} catch (err) {
console.error("Erro ao buscar médico:", err);
Swal.fire("Erro!", err.message || "Erro ao buscar médico.", "error");
}
};
// Função de filtragem (mesmo padrão do PatientList)
const filteredDoctors = doctors.filter(doctor => {
if (!doctor) return false;
// Filtro por texto (nome, especialidade, CRM, email)
const nome = (doctor.full_name || "").toLowerCase();
const crm = (doctor.crm || "").toLowerCase();
const email = (doctor.email || "").toLowerCase();
const cidade = (doctor.city || "").toLowerCase();
const q = search.toLowerCase();
const matchesSearch = nome.includes(q) || crm.includes(q) || email.includes(q) || cidade.includes(q);
// Filtro por especialidade
let matchesSpecialty = true;
if (specialtyFilter) {
const doctorSpecialty = (doctor.specialty || "").toLowerCase().trim();
matchesSpecialty = doctorSpecialty.includes(specialtyFilter.toLowerCase());
}
return matchesSearch && matchesSpecialty;
});
const permissoes = {
admin: ['adddoctor'],
secretaria: [""],
paciente: ['']
};
const pode = (acao) => permissoes[role]?.includes(acao);
return (
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-12">
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Lista de Médicos</h4>
{pode('adddoctor') && (<Link
to={`/${role}/doctorform`}
className="btn btn-primary btn-rounded"
>
<i className="fa fa-plus"></i> Adicionar Médico
</Link>
)}
</div>
{/* Filtros em uma única linha (mesmo padrão do PatientList) */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.5rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar médicos"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: "300px", maxWidth: "450px" }}
/>
{/* Filtro por especialidade */}
<select
className="form-control form-control-sm"
style={{ minWidth: "150px", maxWidth: "200px" }}
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value)}
>
<option value="">Todas as especialidades</option>
<option value="cardiologia">Cardiologia</option>
<option value="pediatria">Pediatria</option>
<option value="neurologia">Neurologia</option>
<option value="ortopedia">Ortopedia</option>
<option value="ginecologia">Ginecologia</option>
<option value="dermatologia">Dermatologia</option>
<option value="psiquiatria">Psiquiatria</option>
<option value="oftalmologia">Oftalmologia</option>
<option value="urologia">Urologia</option>
<option value="endocrinologia">Endocrinologia</option>
<option value="gastroenterologia">Gastroenterologia</option>
<option value="pneumologia">Pneumologia</option>
<option value="oncologia">Oncologia</option>
<option value="reumatologia">Reumatologia</option>
<option value="otorrinolaringologia">Otorrinolaringologia</option>
<option value="anestesiologia">Anestesiologia</option>
<option value="cirurgia geral">Cirurgia Geral</option>
<option value="medicina interna">Medicina Interna</option>
<option value="medicina de família">Medicina de Família</option>
<option value="radiologia">Radiologia</option>
</select>
{/* Contador de resultados */}
<span className="text-muted" style={{ fontSize: "0.875rem", minWidth: "150px" }}>
{filteredDoctors.length} médico(s) encontrado(s)
</span>
</div>
</div>
</div>
<div className="row doctor-grid">
{filteredDoctors.length > 0 ? (
filteredDoctors.map((doctor) => (
<div key={doctor.id} className="col-md-4 col-sm-4 col-lg-3">
<div className="profile-widget">
<div className="doctor-img">
<div className="avatar">
<img alt="" src={AvatarForm} />
</div>
</div>
{/* Dropdown estilizado */}
<div className="dropdown profile-action">
<button
type="button"
className="action-icon"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === doctor.id ? null : doctor.id);
}}
>
<i className="fa fa-ellipsis-v"></i>
</button>
{openDropdown === doctor.id && (
<div
className="dropdown-menu dropdown-menu-right show"
style={{ position: "absolute", zIndex: 1000 }}
>
{/* Ver Detalhes */}
<Link
className="dropdown-item-custom"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
handleViewDetails(doctor.id);
}}
>
<i className="fa fa-eye"></i> Ver Detalhes
</Link>
{/* Edit */}
<Link
className="dropdown-item-custom"
to={`/admin/editdoctor/${doctor.id}`}
>
<i className="fa fa-pencil m-r-5"></i> Editar
</Link>
{/* Delete */}
<button
className="dropdown-item-custom dropdown-item-delete"
onClick={() => handleDelete(doctor.id)}
>
<i className="fa fa-trash-o m-r-5"></i> Delete
</button>
</div>
)}
</div>
<h4 className="doctor-name text-ellipsis">
<Link to={`/admin/profiledoctor/${doctor.id}`}>
{doctor.full_name}
</Link>
</h4>
<div className="doc-prof">{doctor.specialty || 'Não informado'}</div>
<div className="user-country">
<i className="fa fa-map-marker"></i> {doctor.city || 'Não informado'}
</div>
</div>
</div>
))
) : (
<div className="col-12">
<div className="text-center py-5">
<i className="fa fa-user-md fa-3x text-muted mb-3"></i>
<h5 className="text-muted">Nenhum médico encontrado</h5>
<p className="text-muted">
{search || specialtyFilter
? "Tente ajustar os filtros de busca"
: "Nenhum médico cadastrado no sistema"}
</p>
</div>
</div>
)}
</div>
</div>
{/* Modal delete (não alterado) */}
<div id="delete_doctor" className="modal fade delete-modal" role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-body text-center">
<img src="assets/img/sent.png" alt="" width="50" height="46" />
<h3>Are you sure want to delete this Doctor?</h3>
<div className="m-t-20">
<a href="#" className="btn btn-white" data-dismiss="modal">
Close
</a>
<button type="submit" className="btn btn-danger">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default DoctorList;

View File

@ -0,0 +1,755 @@
import { Link } from "react-router-dom";
import React, { useState, useRef, useLayoutEffect, useEffect } from "react";
import { createPortal } from "react-dom";
import { getAccessToken } from "../../utils/auth.js";
import Swal from 'sweetalert2';
import { useResponsive } from '../../utils/useResponsive';
import { useNavigate } from "react-router-dom";
import { getUserRole } from "../../utils/userInfo.js";
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
useLayoutEffect(() => {
if (!isOpen || !anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
if (left < 0) left = scrollX + 4;
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
useEffect(() => {
if (!isOpen) return;
const handleDocClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target) &&
anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
};
const handleScroll = () => onClose();
document.addEventListener("mousedown", handleDocClick);
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div ref={menuRef} className={className} style={stylePos} onClick={(e) => e.stopPropagation()}>
{children}
</div>,
document.body
);
}
function LaudoList() {
const [search, setSearch] = useState("");
const [period, setPeriod] = useState(""); // "", "today", "week", "month"
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [laudos, setLaudos] = useState([])
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({});
const tokenUsuario = getAccessToken()
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || "https://yuanqfswhberkoevtmfr.supabase.co";
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
var myHeaders = new Headers();
myHeaders.append(
"apikey",
supabaseAK
);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/reports`, requestOptions)
.then(response => response.json())
.then(result => setLaudos(Array.isArray(result) ? result : []))
.catch(error => console.log('error', error));
}, [])
const handleVerDetalhes = (laudo) => {
Swal.fire({
title: "Detalhes do Laudo",
html: `
<div class="text-start" style="text-align: left; max-height: 400px; overflow-y: auto;">
<div class="mb-3">
<h6 class="text-primary">Informações do Pedido</h6>
<p><strong> Pedido:</strong> ${laudo.order_number || 'N/A'}</p>
<p><strong>Paciente ID:</strong> ${laudo.patient_id || 'N/A'}</p>
<p><strong>Tipo:</strong> ${laudo.tipo || 'N/A'}</p>
</div>
<div class="mb-3">
<h6 class="text-primary">Detalhes do Exame</h6>
<p><strong>Exame:</strong> ${laudo.exam || 'N/A'}</p>
<p><strong>Diagnóstico:</strong> ${laudo.diagnosis || 'Nenhum diagnóstico'}</p>
<p><strong>Conclusão:</strong> ${laudo.conclusion || 'Nenhuma conclusão'}</p>
</div>
<div class="mb-3">
<h6 class="text-primary">Responsáveis</h6>
<p><strong>Executante:</strong> ${laudo.requested_by || 'N/A'}</p>
</div>
<div class="mb-3">
<h6 class="text-primary">Datas</h6>
<p><strong>Criado em:</strong> ${formatDate(laudo.created_at) || 'N/A'}</p>
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: "Abrir Laudo",
cancelButtonText: "Fechar",
confirmButtonColor: "#3085d6",
cancelButtonColor: "#6c757d",
icon: "info",
width: "600px",
draggable: true
}).then((result) => {
if (result.isConfirmed) {
// Abrir o form de laudo
abrirFormLaudo(laudo.id);
}
});
};
const abrirFormLaudo = (laudoId) => {
// Navega para o form de laudo com o ID
window.location.href = `/${role}/laudoform?id=${laudoId}`;
};
const getStatusBadgeClass = (status) => {
switch (status?.toLowerCase()) {
case 'concluído':
case 'finalizado':
return 'bg-success';
case 'pendente':
return 'bg-warning';
case 'cancelado':
return 'bg-danger';
default:
return 'bg-secondary';
}
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
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'
}).replace(',', ' às');
} catch {
return dateString;
}
};
const handleDelete = (id) => {
Swal.fire({
title: "Tem certeza?",
text: "Tem certeza que deseja excluir este laudo?",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#e63946",
cancelButtonColor: "#6c757d",
confirmButtonText: "Excluir!",
cancelButtonText: "Cancelar",
}).then((result) => {
if (result.isConfirmed) {
var requestOptions = {
method: 'DELETE',
headers: myHeaders,
redirect: 'follow'
};
fetch(`${supabaseUrl}/rest/v1/reports?id=eq.${id}`, requestOptions)
.then(response => response.text())
.then(result => console.log(result))
.catch(error => console.log('error', error));
setLaudos(prev => prev.filter(l => l.id !== id));
setOpenDropdown(null);
Swal.fire({
title: "Excluído!",
text: "Laudo excluído com sucesso.",
icon: "success",
draggable: true
});
}
});
};
const mascararCPF = (cpf = "") => {
if (cpf.length < 5) return cpf;
return `${cpf.slice(0, 3)}.***.***-${cpf.slice(-2)}`;
};
const [pacientesMap, setPacientesMap] = useState({});
// useEffect para atualizar todos os nomes
useEffect(() => {
if (!laudos || laudos.length === 0) return;
const buscarPacientes = async () => {
try {
// Pega IDs únicos de pacientes
const idsUnicos = [...new Set(laudos.map((l) => l.patient_id))];
// Faz apenas 1 fetch por paciente
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
{
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
}
);
const data = await res.json();
return { id, full_name: data[0]?.full_name || "Nome não encontrado" };
} catch (err) {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setPacientesMap(map);
} catch (err) {
console.error("Erro ao buscar pacientes:", err);
}
};
buscarPacientes();
}, [laudos]);
const filteredLaudos = laudos.filter(l => {
const q = search.toLowerCase();
const textMatch =
(pacientesMap[l.patient_id]?.toLowerCase() || "").includes(q) ||
(l.status || "").toLowerCase().includes(q) ||
(l.order_number || "").toString().toLowerCase().includes(q) ||
(l.exam || "").toLowerCase().includes(q) ||
(l.diagnosis || "").toLowerCase().includes(q) ||
(l.conclusion || "").toLowerCase().includes(q);
// Filtro por status
const matchesStatus = !statusFilter || l.status === statusFilter;
let dateMatch = true;
if (l.created_at) {
const laudoDate = new Date(l.created_at);
const today = new Date();
// Filtros por período rápido
if (period === "today") {
const todayStr = today.toDateString();
dateMatch = laudoDate.toDateString() === todayStr;
} else if (period === "week") {
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - today.getDay());
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
dateMatch = laudoDate >= startOfWeek && laudoDate <= endOfWeek;
} else if (period === "month") {
dateMatch = laudoDate.getMonth() === today.getMonth() &&
laudoDate.getFullYear() === today.getFullYear();
}
// Filtros por data específica
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999); // Inclui o dia inteiro
dateMatch = dateMatch && laudoDate >= start && laudoDate <= end;
} else if (startDate) {
const start = new Date(startDate);
dateMatch = dateMatch && laudoDate >= start;
} else if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateMatch = dateMatch && laudoDate <= end;
}
}
return textMatch && matchesStatus && dateMatch;
});
const [itemsPerPage1, setItemsPerPage1] = useState(15);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastLaudos = currentPage1 * itemsPerPage1;
const indexOfFirstLaudos = indexOfLastLaudos - itemsPerPage1;
const currentLaudos = filteredLaudos.slice(indexOfFirstLaudos, indexOfLastLaudos);
const totalPages1 = Math.ceil(filteredLaudos.length / itemsPerPage1);
const navigate = useNavigate();
const [medicosMap, setMedicosMap] = useState({});
// Função para definir períodos e limpar datas
const handlePeriodChange = (newPeriod) => {
// Se clicar no mesmo período, limpa o filtro
if (period === newPeriod) {
setPeriod("");
} else {
setPeriod(newPeriod);
}
// Sempre limpa as datas específicas
setStartDate("");
setEndDate("");
};
useEffect(() => {
setCurrentPage1(1);
}, [search, statusFilter, period, startDate, endDate]);
useEffect(() => {
if (!Array.isArray(laudos) || laudos.length === 0) return;
const buscarMedicos = async () => {
try {
const idsUnicos = [...new Set(laudos.map((c) => c.requested_by).filter(Boolean))];
if (idsUnicos.length === 0) return;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenUsuario}`,
apikey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ",
};
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(`https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/doctors?id=eq.${id}`, {
method: "GET",
headers,
});
if (!res.ok) return { id, full_name: "Nome não encontrado" };
const data = await res.json();
return { id, full_name: data?.[0]?.full_name || "Nome não encontrado" };
} catch {
return { id, full_name: "Nome não encontrado" };
}
});
const results = await Promise.all(promises);
const map = {};
results.forEach((r) => (map[r.id] = r.full_name));
setMedicosMap(map);
} catch (err) {
console.error("Erro ao buscar nomes dos médicos:", err);
}
};
buscarMedicos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laudos]);
const permissoes = {
admin: ['editlaudo', 'deletarlaudo', 'viewlaudo', 'viewpatientlaudos', 'createlaudo', 'executantelaudo'],
medico: ['editlaudo', 'deletarlaudo', 'viewlaudo', 'viewpatientlaudos', 'createlaudo'],
paciente: ['viewlaudo']
};
const pode = (acao) => permissoes[role]?.includes(acao);
// Função para imprimir o laudo (content_html)
const handlePrint = async (laudoId) => {
try {
const res = await fetch(`${supabaseUrl}/rest/v1/reports?id=eq.${laudoId}`, {
method: 'GET',
headers: myHeaders,
});
if (!res.ok) {
Swal.fire({
icon: 'error',
title: 'Erro de autenticação',
text: 'Não foi possível acessar o laudo. Faça login novamente.'
});
return;
}
const data = await res.json();
const contentHtml = data[0]?.content_html;
if (!contentHtml) {
Swal.fire({
icon: 'warning',
title: 'Sem conteúdo',
text: 'Este laudo não possui conteúdo para impressão.'
});
return;
}
// Caminho da logo (ajuste se necessário)
const logoUrl = '/public/img/logomedconnect.png';
const pacienteNome = pacientesMap[data[0]?.patient_id] || 'Paciente';
const pedido = data[0]?.order_number || 'N/A';
const exame = data[0]?.exam || 'N/A';
const dataCriacao = formatDate(data[0]?.created_at);
const medicoNome = medicosMap[data[0]?.requested_by] || data[0]?.requested_by || '';
const printWindow = window.open('', '', 'width=900,height=700');
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Laudo Médico - ${pacienteNome}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.header { display: flex; align-items: center; border-bottom: 2px solid #1976d2; padding-bottom: 16px; margin-bottom: 32px; }
.logo { height: 60px; margin-right: 24px; }
.clinic-info { font-size: 1.2em; color: #1976d2; }
.laudo-title { font-size: 2em; margin: 0; }
.patient-info { margin-bottom: 24px; }
.patient-info strong { color: #1976d2; }
.footer { border-top: 1px solid #ccc; margin-top: 40px; padding-top: 12px; color: #888; font-size: 0.95em; text-align: center; }
</style>
</head>
<body>
<div class="header">
<img src="${logoUrl}" class="logo" alt="Logo"/>
<div>
<div class="clinic-info"><strong>MedConnect</strong> - Sistema de Laudos</div>
<div>${new Date().toLocaleDateString('pt-BR')}</div>
</div>
</div>
<div class="patient-info">
<strong>Paciente:</strong> ${pacienteNome}<br/>
<strong>Pedido:</strong> ${pedido}<br/>
<strong>Exame:</strong> ${exame}<br/>
<strong>Médico:</strong> ${medicoNome}<br/>
<strong>Data:</strong> ${dataCriacao}
</div>
<h2 class="laudo-title" style="color: #1976d2;">Laudo Médico</h2>
<div>${contentHtml}</div>
<div class="footer">
Gerado por MedConnect &copy; ${new Date().getFullYear()}
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
} catch (err) {
Swal.fire({
icon: 'error',
title: 'Erro ao imprimir',
text: 'Não foi possível imprimir o laudo.'
});
}
};
return (
<div className="page-wrapper">
<div className="content">
{/* Header com título e botão */}
<div className="col-12">
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Laudos</h4>
{pode('createlaudo') && (
<Link
to={`/${role}/laudoform`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
className="btn btn-primary btn-rounded"
>
<i className="fa fa-plus"></i> Adicionar Laudo
</Link>
)}
</div>
</div>
{/* Todos os filtros em uma única linha */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.5rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar laudo"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ minWidth: "300px", maxWidth: "450px", }}
/>
{/* Filtro de status */}
<select
className="form-control form-control-sm"
style={{ minWidth: "80px", maxWidth: "125px", }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">Status</option>
<option value="draft">Rascunho</option>
<option value="completed">Concluído</option>
</select>
{/* Filtro De */}
<div className="d-flex align-items-center" style={{ gap: "0.2rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>De:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={startDate}
onChange={e => {
setStartDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Filtro Até */}
<div className="d-flex align-items-center" style={{ gap: "0.2rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>Até:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={endDate}
onChange={e => {
setEndDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Botões rápidos */}
<button
className={`btn btn-sm ${period === "today" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "60px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("today")}
>
Hoje
</button>
<button
className={`btn btn-sm ${period === "week" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "70px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("week")}
>
Semana
</button>
<button
className={`btn btn-sm ${period === "month" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "60px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("month")}
>
Mês
</button>
</div>
{/* Tabela */}
<div className="row">
<div className="col-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
<th className="text-center">Pedido</th>
{pode('viewpatientlaudos') && (
<th className="text-center">Paciente</th>
)}
<th className="text-center">Procedimento</th>
<th className="text-center">Diagnóstico</th>
<th className="text-center">Conclusão</th>
<th className="text-center">Status</th>
{pode('executantelaudo') && (
<th className="text-center">Executante</th>
)}
<th className="text-center">Criado em</th>
<th className="text-center">Ações</th>
</tr>
</thead>
<tbody>
{currentLaudos.length > 0 ? currentLaudos.map(l => (
<tr key={l.id}>
<td className="nowrap">{l.order_number}</td>
{pode('viewpatientlaudos') && (
<td className="text-center">{pacientesMap[l.patient_id] || "Carregando..."}</td>
)}
<td className="text-center">{l.exam}</td>
<td className="text-center">{l.diagnosis}</td>
<td className="text-center">{l.conclusion}</td>
<td className="text-center">
<span
className={`custom-badge ${
l.status === 'draft' ? 'status-orange' :
l.status === 'completed' ? 'status-green' :
'status-gray'
}`}
style={{ minWidth: '110px', display: 'inline-block', textAlign: 'center' }}
>
{l.status === 'draft' ? (
<>
<i className="fa fa-edit" style={{ marginRight: '6px' }}></i>
Rascunho
</>
) : l.status === 'completed' ? (
<>
<i className="fa fa-check-circle" style={{ marginRight: '6px' }}></i>
Concluído
</>
) : (
l.status
)}
</span>
</td>
{pode('executantelaudo') && (
<td className="text-center"> {medicosMap[l.requested_by] || l.requested_by}</td>
)}
<td className="text-center">{formatDate(l.created_at)}</td>
<td className="text-center">
<div className="action-buttons-container">
{pode('editlaudo') && (<button
type="button"
className="action-btn action-btn-edit"
onClick={() => navigate(`/${role}/editlaudo/${l.id}`)}
title="Ver detalhes do paciente"
>
<span className="fa fa-pencil m-r-5"></span>
</button>
)}
<button
type="button"
className="action-btn action-btn-view"
onClick={() => navigate(`/${role}/verlaudo/${l.id}`)}
title="Ver detalhes do paciente"
>
<span className="fa fa-eye"></span>
</button>
{/* Botão de imprimir */}
<button
type="button"
className="action-btn action-btn-print"
onClick={() => handlePrint(l.id)}
title="Imprimir laudo"
>
<span className="fa fa-print"></span>
</button>
{pode('deletarlaudo') && (
<button
type="button"
className="action-btn action-btn-delete"
onClick={() => handleDelete(l.id)}
title="Excluir paciente"
>
<span className="fa fa-trash-o"></span>
</button>
)}
</div>
</td>
</tr>
)) : (
<tr>
<td colSpan="10" className="text-center text-muted">Nenhum laudo encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="d-flex flex-wrap align-items-center mt-3">
<div className="me-3 text-muted" style={{ minWidth: '140px', fontSize: '0.98em', paddingRight: '3%' }}>
Total encontrados: <b>{filteredLaudos.length}</b>
</div>
<div style={{ minWidth: '140px' }}>
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px", display: 'inline-block' }}
value={itemsPerPage1}
onChange={e => {
setItemsPerPage1(Number(e.target.value));
setCurrentPage1(1);
}}
title="Itens por página"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
<option value={30}>30 por página</option>
</select>
</div>
</div>
<div className="w-100 d-flex justify-content-center mt-2">
<nav>
<ul className="pagination mb-0 justify-content-center">
{/* Primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"}
</button>
</li>
{/* Página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.max(prev - 1, 1))}>
&lt;
</button>
</li>
{/* Número da página atual */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.min(prev + 1, totalPages1))}>
&gt;
</button>
</li>
{/* Última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"}
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
);
}
export default LaudoList;

View File

@ -0,0 +1,616 @@
import { Link } from "react-router-dom";
import "../../assets/css/index.css";
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import supabase from "../../Supabase.js";
import { getAccessToken } from "../../utils/auth.js";
import Swal from "sweetalert2";
import '../../assets/css/modal-details.css';
const AvatarForm = "/img/AvatarForm.jpg";
import { useNavigate } from "react-router-dom";
import { getUserRole } from "../../utils/userInfo.js";
// Componente que renderiza o menu em um portal (document.body) e posiciona em relação ao botão
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
// Posiciona o menu após renderar (medir tamanho do menu)
useLayoutEffect(() => {
if (!isOpen) return;
if (!anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// tenta alinhar à direita do botão (como dropdown-menu-right)
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
// evita sair da esquerda da tela
if (left < 0) left = scrollX + 4;
// se extrapolar bottom, abre para cima
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
// fecha ao clicar fora / ao rolar
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
const menu = menuRef.current;
if (menu && !menu.contains(e.target) && anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
// captura scroll em qualquer elemento (true)
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className} // mantém as classes que você já usa no CSS
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function PatientList() {
const [search, setSearch] = useState("");
const [sexFilter, setSexFilter] = useState(""); // Filtro por sexo
const [patients, setPatients] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({}); // guarda referência do botão de cada linha
// 🟢 ADICIONADO controla o modal e o paciente selecionado
const [selectedPatient, setSelectedPatient] = useState(null);
const [showModal, setShowModal] = useState(false);
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const handleViewDetails = (patient) => {
const mascararCPF = (cpf = "") => {
if (cpf.length < 5) return cpf;
const inicio = cpf.slice(0, 3);
const fim = cpf.slice(-2);
return `${inicio}.***.***-${fim}`;
};
Swal.fire({
title: `<h4 style="margin-bottom:10px;">Detalhes do Paciente</h4>`,
html: `
<div style="text-align:center;">
<img
src="${patient.profile_photo_url || AvatarForm}"
alt="${patient.full_name}"
style="
width:120px;
height:120px;
border-radius:50%;
object-fit:cover;
border:3px solid #4dabf7;
box-shadow:0 4px 8px rgba(0,0,0,0.1);
"
onerror="this.src='${AvatarForm}'"
/>
<h5 style="margin-top:10px;">${patient.full_name}</h5>
<p class="text-muted">Informações detalhadas sobre o paciente.</p>
</div>
<div style="text-align:left; margin-top:20px;">
<div style="display:flex; justify-content:space-between;">
<div style="width:48%;">
<p><strong>Nome Completo:</strong> ${patient.full_name}</p>
<p><strong>Telefone:</strong> ${patient.phone_mobile}</p>
<p><strong>CPF:</strong> ${mascararCPF(patient.cpf)}</p>
<p><strong>Peso (kg):</strong> ${patient.weight || "—"}</p>
<p><strong>Endereço:</strong> ${patient.address || "—"}</p>
</div>
<div style="width:48%;">
<p><strong>Email:</strong> ${patient.email}</p>
<p><strong>Data de Nascimento:</strong> ${patient.birth_date}</p>
<p><strong>Tipo Sanguíneo:</strong> ${patient.blood_type || "—"}</p>
<p><strong>Altura (m):</strong> ${patient.height || "—"}</p>
</div>
</div>
</div>
`,
width: "800px",
showConfirmButton: true,
confirmButtonText: "Fechar",
confirmButtonColor: "#4dabf7",
background: document.body.classList.contains("dark-mode")
? "#1e1e2f"
: "#fff",
color: document.body.classList.contains("dark-mode")
? "#f5f5f5"
: "#000",
customClass: {
popup: 'swal2-modal-patient'
}
});
};
const tokenUsuario = getAccessToken()
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions)
.then(response => response.json())
.then(result => {
setPatients(Array.isArray(result) ? result : [])
console.log(result);
})
.catch(error => console.log('error', error));
}, [])
const handleDelete = async (id) => {
if (getUserRole() === 'paciente' || getUserRole() === 'medico') {
Swal.fire("Ação não permitida", "Pacientes e médicos não podem excluir pacientes. Por favor, entre em contato com a secretaria.", "warning");
return;
}
Swal.fire({
title: "Tem certeza?",
text: "Tem certeza que deseja excluir este paciente?",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#e63946",
cancelButtonColor: "#6c757d",
confirmButtonText: "Excluir!",
cancelButtonText: "Cancelar",
}).then(async (result) => {
if (result.isConfirmed) {
try {
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'DELETE',
headers: myHeaders,
redirect: 'follow'
};
const response = await fetch(`${supabaseUrl}/rest/v1/patients?id=eq.${id}`, requestOptions)
if (response.ok) {
setPatients(prev => prev.filter(l => l.id !== id));
setOpenDropdown(null);
Swal.fire({
title: "Registro Excluído",
text: "Registro excluído com sucesso",
icon: "success"
})
} else {
Swal.fire("Error saving changes", "", "error");
}
}
catch (error) {
Swal.fire("Something went wrong", "", "error");
console.error(error);
}
}
});
};
const [birthDateStart, setBirthDateStart] = useState("");
const [birthDateEnd, setBirthDateEnd] = useState("");
const [ageRange, setAgeRange] = useState("");
const [bloodType, setBloodType] = useState("");
const filteredPatients = patients.filter(p => {
if (!p) return false;
// Filtro por texto (nome, cpf, email)
const nome = (p.full_name || "").toLowerCase();
const cpf = (p.cpf || "").toLowerCase();
const email = (p.email || "").toLowerCase();
const data = (p.birth_date || "").toLowerCase();
const q = search.toLowerCase();
const matchesSearch = nome.includes(q) || cpf.includes(q) || email.includes(q) || data.includes(q);
// Filtro por sexo (flexível - aceita diferentes variações)
let matchesSex = true;
if (sexFilter) {
const patientSex = (p.sex || "").toLowerCase().trim();
if (sexFilter === "masculino") {
matchesSex = patientSex === "masculino" || patientSex === "m" || patientSex === "male";
} else if (sexFilter === "feminino") {
matchesSex = patientSex === "feminino" || patientSex === "f" || patientSex === "female";
} else if (sexFilter === "outros") {
matchesSex = !["masculino", "m", "male", "feminino", "f", "female", ""].includes(patientSex);
}
}
// Filtro por data de nascimento
let matchesBirthDate = true;
if (birthDateStart) {
matchesBirthDate = p.birth_date >= birthDateStart;
}
if (matchesBirthDate && birthDateEnd) {
matchesBirthDate = p.birth_date <= birthDateEnd;
}
// Filtro por faixa etária
let matchesAge = true;
if (ageRange && p.birth_date) {
const today = new Date();
const birth = new Date(p.birth_date);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
age--;
}
if (ageRange === "0-18") matchesAge = age >= 0 && age <= 18;
else if (ageRange === "19-40") matchesAge = age >= 19 && age <= 40;
else if (ageRange === "41-60") matchesAge = age >= 41 && age <= 60;
else if (ageRange === "60+") matchesAge = age > 60;
}
// Filtro por tipo sanguíneo
let matchesBlood = true;
if (bloodType) {
matchesBlood = (p.blood_type || "").toUpperCase() === bloodType;
}
return matchesSearch && matchesSex && matchesBirthDate && matchesAge && matchesBlood;
});
const [itemsPerPage1, setItemsPerPage1] = useState(15);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastPatient = currentPage1 * itemsPerPage1;
const indexOfFirstPatient = indexOfLastPatient - itemsPerPage1;
const currentPatients = filteredPatients.slice(indexOfFirstPatient, indexOfLastPatient);
const totalPages1 = Math.ceil(filteredPatients.length / itemsPerPage1);
useEffect(() => {
setCurrentPage1(1);
}, [search, sexFilter]);
const mascararCPF = (cpf = "") => {
if (cpf.length < 5) return cpf;
const inicio = cpf.slice(0, 3);
const fim = cpf.slice(-2);
return `${inicio}.***.***-${fim}`;
};
const renderSexBadge = (sex) => {
const sexo = (sex || "").toLowerCase().trim();
if (sexo === "masculino" || sexo === "m" || sexo === "male") {
return (
<span
className="custom-badge status-blue"
style={{ minWidth: '90px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<i className="fa fa-mars" style={{ marginRight: '6px' }}></i>
Masculino
</span>
);
} else if (sexo === "feminino" || sexo === "f" || sexo === "female") {
return (
<span
className="custom-badge status-pink"
style={{ minWidth: '90px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<i className="fa fa-venus" style={{ marginRight: '6px' }}></i>
Feminino
</span>
);
} else if (sexo === "") {
return (
<span
className="custom-badge status-red"
style={{ minWidth: '90px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<i className="fa fa-question" style={{ marginRight: '6px' }}></i>
Em branco
</span>
);
} else {
return (
<span
className="custom-badge status-purple"
style={{ minWidth: '90px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<i className="fa fa-genderless" style={{ marginRight: '6px' }}></i>
{sexo || "Outros"}
</span>
);
}
};
const navigate = useNavigate();
const permissoes = {
admin: ['editpatient', 'deletepatient'],
medico: [''],
secretaria: ['editpatient', 'deletepatient'],
};
const pode = (acao) => permissoes[role]?.includes(acao);
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-12">
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Lista de Pacientes</h4>
<Link to={`/${role}/patientform`} className="btn btn-primary btn-rounded">
<i className="fa fa-plus"></i> Adicionar Paciente
</Link>
</div>
{/* Todos os filtros em uma única linha */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.5rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar pacientes"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: "300px", maxWidth: "450px" }}
/>
{/* Filtro por sexo */}
<select
className="form-control form-control-sm"
style={{ minWidth: "100px", maxWidth: "140px" }}
value={sexFilter}
onChange={(e) => setSexFilter(e.target.value)}
>
<option value="">Todos os sexos</option>
<option value="masculino">Masculino</option>
<option value="feminino">Feminino</option>
<option value="outros">Outros</option>
</select>
{/* Filtro por data de nascimento (de) */}
<span style={{ fontSize: '0.95em', marginLeft: '8px', marginRight: '2px' }}>De:</span>
<input
type="date"
className="form-control form-control-sm"
value={birthDateStart}
onChange={e => setBirthDateStart(e.target.value)}
style={{ minWidth: "150px", maxWidth: "180px" }}
placeholder="Nascimento de"
title="Nascimento de"
/>
{/* Filtro por data de nascimento (até) */}
<span style={{ fontSize: '0.95em', marginLeft: '8px', marginRight: '2px' }}>Até:</span>
<input
type="date"
className="form-control form-control-sm"
value={birthDateEnd}
onChange={e => setBirthDateEnd(e.target.value)}
style={{ minWidth: "150px", maxWidth: "180px" }}
placeholder="Nascimento até"
title="Nascimento até"
/>
{/* Filtro por faixa etária */}
<select
className="form-control form-control-sm"
style={{ minWidth: "120px", maxWidth: "160px" }}
value={ageRange}
onChange={e => setAgeRange(e.target.value)}
>
<option value="">Todas as idades</option>
<option value="0-18">0-18 anos</option>
<option value="19-40">19-40 anos</option>
<option value="41-60">41-60 anos</option>
<option value="60+">60+ anos</option>
</select>
{/* Filtro por tipo sanguíneo */}
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px" }}
value={bloodType}
onChange={e => setBloodType(e.target.value)}
>
<option value="">Tipo Sanguíneo</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>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
<th>Nome</th>
<th>Cpf</th>
<th>Data de Nascimento</th>
<th>Telefone</th>
<th>Email</th>
<th className="text-center">Sexo</th>
<th className="text-center">Ações</th>
</tr>
</thead>
<tbody>
{currentPatients.length > 0 ? (
currentPatients.map((p) => (
<tr key={p.id}>
<td>
<div className="table-avatar">
<div className="upload-img">
<img
alt={p.full_name}
src={p.profile_photo_url || AvatarForm}
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
objectFit: "cover"
}}
onError={(e) => {
e.target.src = AvatarForm; // Fallback se a imagem não carregar
}}
/>
<span style={{ marginLeft: "4px" }}>{p.full_name}</span>
</div>
</div>
</td>
<td>{mascararCPF(p.cpf)}</td>
<td>{p.birth_date}</td>
<td>{p.phone_mobile}</td>
<td>{p.email}</td>
<td className="text-center">{renderSexBadge(p.sex)}</td>
<td className="text-center">
<div className="action-buttons-container">
<button
type="button"
className="action-btn action-btn-view"
onClick={() => handleViewDetails(p)}
title="Ver detalhes do paciente"
>
<span className="fa fa-eye"></span>
</button>
{pode('editpatient') && (
<button
type="button"
className="action-btn action-btn-edit"
onClick={() => navigate(`/${role}/editpatient/${p.id}`)}
title="Ver detalhes do paciente"
>
<span className="fa fa-pencil m-r-5"></span>
</button>
)}
{pode('deletepatient') && (
<button
type="button"
className="action-btn action-btn-delete"
onClick={() => handleDelete(p.id)}
title="Excluir paciente"
>
<span className="fa fa-trash-o"></span>
</button>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="text-center text-muted">
Nenhum paciente encontrado
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Linha de controles abaixo da tabela */}
<div className="d-flex flex-wrap align-items-center mt-3">
<div className="me-3 text-muted" style={{ minWidth: '140px', fontSize: '0.98em', paddingRight: '3%' }}>
Total encontrados: <b>{filteredPatients.length}</b>
</div>
<div style={{ minWidth: '140px' }}>
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px", display: 'inline-block' }}
value={itemsPerPage1}
onChange={e => {
setItemsPerPage1(Number(e.target.value));
setCurrentPage1(1);
}}
title="Itens por página"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
<option value={30}>30 por página</option>
</select>
</div>
</div>
<div className="w-100 d-flex justify-content-center mt-2">
<nav>
<ul className="pagination mb-0 justify-content-center">
{/* Primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"}
</button>
</li>
{/* Página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.max(prev - 1, 1))}>
&lt;
</button>
</li>
{/* Número da página atual */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.min(prev + 1, totalPages1))}>
&gt;
</button>
</li>
{/* Última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"}
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default PatientList;

View File

@ -1,81 +1,13 @@
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import "./assets/css/index.css";
// Layouts
import App from "./App.jsx"; // Layout Admin
import DoctorApp from "./pages/DoctorApp/DoctorApp.jsx"; // Layout Médico
// Páginas Admin
import Patientform from "./pages/Patient/Patientform.jsx";
import PatientList from "./pages/Patient/PatientList.jsx";
import Doctorlist from "./pages/Doctor/DoctorList.jsx";
import DoctorForm from "./pages/Doctor/DoctorForm.jsx";
import Doctorschedule from "./pages/Schedule/DoctorSchedule.jsx";
import AddSchedule from "./pages/Schedule/AddSchedule.jsx";
import Calendar from "./pages/calendar/Calendar.jsx";
import EditDoctor from "./pages/Doctor/DoctorEdit.jsx";
import PatientEdit from "./pages/Patient/PatientEdit.jsx";
import DoctorProfile from "./pages/Doctor/DoctorProfile.jsx";
// Páginas Médico
import DoctorDashboard from "./pages/DoctorApp/DoctorDashboard.jsx";
import DoctorCalendar from "./pages/DoctorApp/DoctorCalendar.jsx";
import DoctorPatientList from "./pages/DoctorApp/DoctorPatientList.jsx";
import AgendaList from "./pages/Agendar/AgendaList.jsx";
import AgendaForm from "./pages/Agendar/AgendaForm.jsx";
import AgendaEdit from "./pages/Agendar/AgendaEdit.jsx";
import LaudoList from "./pages/laudos/LaudosList.jsx"
import Laudo from "./pages/laudos/Laudo.jsx";
import { router } from "./routes/RoutesApp.jsx";
// Criando o router com todas as rotas
const router = createBrowserRouter([
// Rotas Admin
{
path: "/",
element: <App />,
children: [
// Rota inicial do Admin: apenas mostra layout com Navbar e Sidebar
{ path: "patient", element: <Patientform /> },
{ path: "patientlist", element: <PatientList /> },
{ path: "doctorlist", element: <Doctorlist /> },
{ path: "doctorform", element: <DoctorForm /> },
{ path: "doctorschedule", element: <Doctorschedule /> },
{ path: "addschedule", element: <AddSchedule /> },
{ path: "calendar", element: <Calendar /> },
{ path: "profiledoctor/:id", element: <DoctorProfile /> },
{ path: "editdoctor/:id", element: <EditDoctor /> },
{ path: "editpatient/:id", element: <PatientEdit /> },
{ path: "agendaform", element: <AgendaForm />},
{ path: "agendaedit", element: <AgendaEdit />},
{ path: "agendalist", element: <AgendaList />},
{ path: "laudolist", element: <LaudoList /> },
{ path: "laudo", element: <Laudo />}
],
},
// Rotas Médico
{
path: "/doctor",
element: <DoctorApp />,
children: [
{ index: true, element: <DoctorDashboard /> }, // Rota inicial médico
{ path: "dashboard", element: <DoctorDashboard /> },
{ path: "calendar", element: <DoctorCalendar /> },
{ path: "patients", element: <DoctorPatientList /> },
],
},
]);
// Renderizando a aplicação
createRoot(document.getElementById("root")).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
);

View File

@ -0,0 +1,49 @@
import '../../assets/css/index.css'
import { Link, useLocation } from 'react-router-dom';
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { getAccessToken } from '../../utils/auth.js';
import { getUserRole } from '../../utils/userInfo.js';
import Sidebar from '../../components/layouts/Sidebar.jsx';
function AdminApp() {
const token = getAccessToken();
const user = getUserRole();
// Verificação de autenticação
if (!token) {
return <Navigate to="/login" replace />;
}
// Verificação de role
if (user !== 'admin') {
return (
<div className="page-wrapper">
<div className="content">
<div className="alert alert-danger text-center">
<h4> Acesso Negado</h4>
<p>Apenas administradores podem acessar esta área.</p>
<button
className="btn btn-primary"
onClick={() => window.history.back()}
>
Voltar
</button>
</div>
</div>
</div>
);
}
return (
<div>
<Sidebar />
<Outlet />
</div>
);
}
export { AdminApp };
export default AdminApp;

View File

@ -0,0 +1,874 @@
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import "../../assets/css/index.css";
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import { getFullName, getUserId } from "../../utils/userInfo.js";
// Usando URLs das imagens no public
const AvatarForm = "/img/AvatarForm.jpg";
const banner = "/img/banner.png";
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend as ChartLegend,
} from 'chart.js';
import { Bar as ChartJSBar } from 'react-chartjs-2';
// Registrar componentes do Chart.js
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
ChartTooltip,
ChartLegend
);
// Componente do gráfico de consultas mensais
const ConsultasMensaisChart = ({ data }) => (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mes" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px'
}}
formatter={(value) => [`${value} consultas`, 'Total']}
/>
<Legend />
<Bar dataKey="consultas" fill="#007bff" name="Consultas" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
// Componente do gráfico de pacientes ativos/inativos
const AtivosInativosChart = ({ data }) => (
<ResponsiveContainer width="100%" height={350}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={120}
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px'
}}
formatter={(value, name) => [`${value} pacientes`, name]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
);
// Componente do gráfico de taxa de cancelamentos
const TaxaCancelamentosChart = ({ data }) => {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado de cancelamentos encontrado</p>
</div>
</div>
);
}
// Preparar dados para Chart.js (gráfico empilhado)
const chartData = {
labels: data.map(item => item.mes),
datasets: [
{
label: 'Realizadas',
data: data.map(item => item.realizadas),
backgroundColor: '#dee2e6',
borderColor: '#adb5bd',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
},
{
label: 'Canceladas',
data: data.map(item => item.canceladas),
backgroundColor: '#dc3545',
borderColor: '#c82333',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
}
}
},
y: {
stacked: true,
beginAtZero: true,
max: 100,
grid: {
color: '#e9ecef',
drawBorder: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
},
callback: function(value) {
return value + '%';
}
}
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#495057',
font: {
size: 12
},
usePointStyle: true,
pointStyle: 'rect'
}
},
tooltip: {
backgroundColor: '#f8f9fa',
titleColor: '#343a40',
bodyColor: '#343a40',
borderColor: '#dee2e6',
borderWidth: 1,
callbacks: {
label: function(context) {
const datasetLabel = context.dataset.label;
const value = context.parsed.y;
const dataIndex = context.dataIndex;
const monthData = data[dataIndex];
if (datasetLabel === 'Canceladas') {
const numConsultas = Math.round(monthData.total * value / 100);
return `${datasetLabel}: ${value}% (${numConsultas} de ${monthData.total} consultas)`;
} else {
const numConsultas = Math.round(monthData.total * value / 100);
return `${datasetLabel}: ${value}% (${numConsultas} consultas)`;
}
},
title: function(context) {
const monthData = data[context[0].dataIndex];
return `${context[0].label} ${new Date().getFullYear()} - Total: ${monthData.total} consultas`;
},
afterBody: function(context) {
const monthData = data[context[0].dataIndex];
if (monthData.total === 0) {
return ['Nenhuma consulta registrada neste mês'];
}
return [];
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
layout: {
padding: {
left: 10,
right: 10,
top: 10,
bottom: 10
}
}
};
return (
<div style={{ width: '100%', height: '350px', backgroundColor: '#ffffff' }}>
<ChartJSBar data={chartData} options={options} />
</div>
);
};
// Componente do gráfico de consultas por médico com Chart.js (horizontal)
const ConsultasPorMedicoChart = ({ data }) => {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted" style={{ height: '400px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado de médicos encontrado</p>
</div>
</div>
);
}
// Preparar dados para Chart.js
const chartData = {
labels: data.map(item => item.medico),
datasets: [
{
label: 'Consultas',
data: data.map(item => item.consultas),
backgroundColor: '#28a745',
borderColor: '#1e7e34',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
}
]
};
const options = {
indexAxis: 'y', // Torna o gráfico horizontal
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false, // Ocultar legenda pois é óbvio
},
tooltip: {
backgroundColor: '#f8f9fa',
titleColor: '#343a40',
bodyColor: '#343a40',
borderColor: '#dee2e6',
borderWidth: 1,
callbacks: {
label: function(context) {
return `${context.parsed.x} consultas`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
color: '#e9ecef',
drawBorder: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
}
}
},
y: {
grid: {
display: false,
},
ticks: {
color: '#6c757d',
font: {
size: 11
},
maxRotation: 0,
// Remover callback que truncava os nomes - mostrar nomes completos
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
layout: {
padding: {
left: 20,
right: 30,
top: 10,
bottom: 10
}
},
elements: {
bar: {
borderRadius: 4,
}
}
};
return (
<div style={{ width: '100%', height: '350px', backgroundColor: '#ffffff' }}>
<ChartJSBar data={chartData} options={options} />
</div>
);
};
function AdminDashboard() {
const [patients, setPatients] = useState([]);
const [doctors, setDoctors] = useState([]);
const [consulta, setConsulta] = useState([]);
const [countPaciente, setCountPaciente] = useState(0);
const [countMedico, setCountMedico] = useState(0);
// Estados para os gráficos
const [consultasMensaisDataReal, setConsultasMensaisDataReal] = useState([]);
const [pacientesStatusDataReal, setPacientesStatusDataReal] = useState([]);
const [consultasPorMedicoData, setConsultasPorMedicoData] = useState([]);
const [taxaCancelamentosData, setTaxaCancelamentosData] = useState([]);
const [appointments, setAppointments] = useState([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [previewUrl, setPreviewUrl] = useState(AvatarForm);
const tokenUsuario = getAccessToken();
const userId = getUserId();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const requestOptions = {
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
redirect: "follow",
};
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
// Buscar pacientes
const patientsResponse = await fetch(
`${supabaseUrl}/rest/v1/patients`,
requestOptions
);
const patientsData = await patientsResponse.json();
const patientsArr = Array.isArray(patientsData) ? patientsData : [];
setPatients(patientsArr);
setConsulta(patientsArr);
setCountPaciente(patientsArr.length);
// Processar status dos pacientes
if (patientsArr.length > 0) {
const ativos = patientsArr.filter(p => p.active !== false).length;
const inativos = patientsArr.length - ativos;
const statusData = [
{ name: 'Ativos', value: ativos, color: '#007bff' },
{ name: 'Inativos', value: inativos, color: '#ffa500' }
];
setPacientesStatusDataReal(statusData);
}
// Buscar médicos
const doctorsResponse = await fetch(
`${supabaseUrl}/rest/v1/doctors`,
requestOptions
);
const doctorsData = await doctorsResponse.json();
const doctorsArr = Array.isArray(doctorsData) ? doctorsData : [];
setDoctors(doctorsArr);
setCountMedico(doctorsArr.length);
// Buscar consultas
const appointmentsResponse = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
requestOptions
);
const appointmentsData = await appointmentsResponse.json();
const appointmentsArr = Array.isArray(appointmentsData) ? appointmentsData : [];
setAppointments(appointmentsArr);
// Processar dados dos gráficos
processConsultasMensais(appointmentsArr);
await processConsultasPorMedico(appointmentsArr, doctorsArr);
processTaxaCancelamentos(appointmentsArr);
} catch (error) {
} finally {
setLoading(false);
}
};
loadData();
}, []);
// useEffect para atualizar o relógio em tempo real
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000); // Atualiza a cada segundo
return () => clearInterval(timer); // Limpa o timer quando o componente é desmontado
}, []);
// useEffect para carregar avatar do usuário (mesma lógica da navbar)
useEffect(() => {
const loadAvatar = async () => {
if (!userId) return;
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
const requestOptions = {
headers: myHeaders,
method: 'GET',
redirect: 'follow'
};
try {
const response = await fetch(`${supabaseUrl}/storage/v1/object/avatars/${userId}/avatar.png`, requestOptions);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setPreviewUrl(imageUrl);
return; // Avatar encontrado
}
} catch (error) {
}
// Se chegou até aqui, não encontrou avatar - mantém o padrão
};
loadAvatar();
}, [userId]);
// Processar dados das consultas mensais
const processConsultasMensais = (appointmentsData) => {
const meses = [
'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'
];
const consultasPorMes = meses.map(mes => ({ mes, consultas: 0 }));
if (appointmentsData && appointmentsData.length > 0) {
appointmentsData.forEach(appointment => {
if (appointment.scheduled_at) {
const data = new Date(appointment.scheduled_at);
const mesIndex = data.getMonth();
if (mesIndex >= 0 && mesIndex < 12) {
consultasPorMes[mesIndex].consultas++;
}
}
});
}
setConsultasMensaisDataReal(consultasPorMes);
};
// Processar dados da taxa de cancelamentos
const processTaxaCancelamentos = (appointmentsData) => {
const meses = [
'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'
];
const cancelamentosPorMes = meses.map(mes => ({
mes,
realizadas: 0,
canceladas: 0,
total: 0
}));
if (appointmentsData && appointmentsData.length > 0) {
appointmentsData.forEach(appointment => {
if (appointment.scheduled_at) {
const data = new Date(appointment.scheduled_at);
const mesIndex = data.getMonth();
const anoAtual = new Date().getFullYear();
const anoConsulta = data.getFullYear();
// Processar apenas consultas do ano atual
if (mesIndex >= 0 && mesIndex < 12 && anoConsulta === anoAtual) {
cancelamentosPorMes[mesIndex].total++;
// Verificar diferentes possíveis campos de status de cancelamento
const isCancelled =
appointment.status === 'cancelled' ||
appointment.status === 'canceled' ||
appointment.cancelled === true ||
appointment.is_cancelled === true ||
appointment.appointment_status === 'cancelled' ||
appointment.appointment_status === 'canceled';
if (isCancelled) {
cancelamentosPorMes[mesIndex].canceladas++;
} else {
cancelamentosPorMes[mesIndex].realizadas++;
}
}
}
});
// Calcular porcentagens e manter valores absolutos para tooltip
cancelamentosPorMes.forEach(mes => {
if (mes.total > 0) {
const realizadasCount = mes.realizadas;
const canceladasCount = mes.canceladas;
mes.realizadas = Math.round((realizadasCount / mes.total) * 100);
mes.canceladas = Math.round((canceladasCount / mes.total) * 100);
// Garantir que soma seja 100%
if (mes.realizadas + mes.canceladas !== 100 && mes.total > 0) {
mes.realizadas = 100 - mes.canceladas;
}
} else {
// Se não há dados, mostrar 100% realizadas
mes.realizadas = 100;
mes.canceladas = 0;
}
});
setTaxaCancelamentosData(cancelamentosPorMes);
} else {
setTaxaCancelamentosData([]);
}
};
// Processar dados das consultas por médico
const processConsultasPorMedico = async (appointmentsData, doctorsData) => {
try {
// Criar mapa de médicos
const doctorsMap = {};
doctorsData.forEach(doctor => {
let doctorName = doctor.full_name || doctor.name || `Médico ${doctor.id}`;
// Apenas limpar espaços em branco, manter nome completo
doctorName = doctorName.trim();
doctorsMap[doctor.id] = doctorName;
});
// Contar consultas por médico
const consultasPorMedico = {};
appointmentsData.forEach(appointment => {
if (appointment.doctor_id) {
const doctorName = doctorsMap[appointment.doctor_id] || `Médico ${appointment.doctor_id}`;
consultasPorMedico[doctorName] = (consultasPorMedico[doctorName] || 0) + 1;
}
});
// Converter para array e ordenar por número de consultas (maior para menor)
const chartData = Object.entries(consultasPorMedico)
.map(([medico, consultas]) => ({ medico, consultas }))
.sort((a, b) => b.consultas - a.consultas)
.slice(0, 10); // Mostrar apenas os top 10 médicos
setConsultasPorMedicoData(chartData);
} catch (error) {
setConsultasPorMedicoData([]);
}
};
return (
<div className="page-wrapper">
<div className="content">
{/* Header com informações do admin */}
<div className="page-header">
<div className="row">
<div className="col-sm-12">
<div className="user-info-banner" style={{
background: `linear-gradient(135deg, #004a99, #0077cc), url(${banner})`,
backgroundSize: 'cover',
borderRadius: '15px',
padding: '30px',
color: 'white',
marginBottom: '20px'
}}>
<div className="row align-items-center">
<div className="col-md-8">
<h2 className="mb-2" style={{color: 'white'}}>👨💼 Olá, {getFullName()}!</h2>
<p className="mb-2" style={{ color: 'white' }}>É ótimo -lo novamente no MediConnect. Acompanhe o desempenho da sua clínica, mantenha o controle de tudo em um lugar e continue fazendo-a crescer todos os dias!
</p>
<small className="text-muted"></small>
<small className="opacity-75">
🕒 {currentTime.toLocaleString('pt-BR')}
</small>
</div>
<div className="col-md-4 text-right">
<img
src={previewUrl}
alt="Avatar"
className="rounded-circle"
style={{ width: '80px', height: '80px', objectFit: 'cover', border: '3px solid white' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Cards de estatísticas */}
<div className="row">
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg2">
<i className="fa fa-users" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{countPaciente}</h3>
<span className="widget-title2">Total Pacientes</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg1">
<i className="fa fa-user-md" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{countMedico}</h3>
<span className="widget-title1">Total Médicos</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg3">
<i className="fa fa-stethoscope" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{appointments.length}</h3>
<span className="widget-title3">Total Consultas</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg4">
<i className="fa fa-heartbeat" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>80</h3>
<span className="widget-title4">Atendidos</span>
</div>
</div>
</div>
</div>
{/* Seção dos Gráficos */}
<div className="row">
{/* Consultas por Mês */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">📊 Consultas por Mês ({new Date().getFullYear()})</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : consultasMensaisDataReal.length > 0 ? (
<ConsultasMensaisChart data={consultasMensaisDataReal} />
) : (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado encontrado</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Top 10 Médicos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">🏆 Top 10 Médicos (Consultas)</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : (
<ConsultasPorMedicoChart data={consultasPorMedicoData} />
)}
</div>
</div>
</div>
</div>
<div className="row">
{/* Pacientes Ativos/Inativos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">👥 Pacientes Ativos x Inativos</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : pacientesStatusDataReal.length > 0 ? (
<AtivosInativosChart data={pacientesStatusDataReal} />
) : (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-pie-chart fa-2x mb-2"></i>
<p>Nenhum dado encontrado</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Taxa de Cancelamentos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">📉 Taxa de Cancelamentos</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : (
<TaxaCancelamentosChart data={taxaCancelamentosData} />
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// CSS customizado para o AdminDashboard (mesmo estilo do PatientDashboard)
const style = document.createElement('style');
style.textContent = `
.user-info-banner {
position: relative;
overflow: hidden;
}
.user-info-banner::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="0%" r="100%"><stop offset="0%" style="stop-color:rgba(255,255,255,0.1)"/><stop offset="100%" style="stop-color:rgba(255,255,255,0)"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
pointer-events: none;
}
.dash-widget {
transition: transform 0.2s ease;
}
.dash-widget:hover {
transform: translateY(-3px);
}
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
}
`;
if (!document.head.querySelector('[data-admin-dashboard-styles]')) {
style.setAttribute('data-admin-dashboard-styles', 'true');
document.head.appendChild(style);
}
export default AdminDashboard;

View File

@ -0,0 +1,711 @@
import { useEffect, useState } from "react";
import { getAccessToken } from "../../utils/auth";
import Swal from 'sweetalert2';
function CreateUser() {
const tokenUsuario = getAccessToken()
const [search, setSearch] = useState("");
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [roleFilter, setRoleFilter] = useState("");
const [period, setPeriod] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [formData, setFormData] = useState({
full_name: "",
email: "",
phone: "",
cpf: "",
role: "secretaria",
password: ""
});
const [submitting, setSubmitting] = useState(false);
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const getHeaders = () => {
const token = getAccessToken();
return {
"apikey": supabaseAK,
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
};
};
const formatDate = (isoString) => {
if (!isoString) return "-";
const date = new Date(isoString);
return date.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const fetchUsersAndRoles = async () => {
try {
const headers = getHeaders();
// Buscar perfis
const resProfiles = await fetch(
`${supabaseUrl}/rest/v1/profiles`,
{ method: "GET", headers }
);
if (!resProfiles.ok) throw new Error("Erro ao buscar perfis");
const profiles = await resProfiles.json();
// Buscar roles dos usuários
const resRoles = await fetch(
`${supabaseUrl}/rest/v1/user_roles`,
{ method: "GET", headers }
);
if (!resRoles.ok) throw new Error("Erro ao buscar roles");
const roles = await resRoles.json();
// Merge profiles com roles
const merged = profiles.map((profile) => {
const userRoles = roles.filter((r) => r.user_id === profile.id);
const cargos = userRoles.length > 0 ? userRoles.map(r => r.role).join(", ") : "Sem cargo";
return {
...profile,
role: cargos,
};
});
setUsers(merged);
} catch (err) {
console.error("Erro ao carregar usuários e roles:", err);
Swal.fire({
title: "Erro!",
text: "Erro ao carregar usuários",
icon: "error",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsersAndRoles();
}, []);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleCreateUser = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
// headers
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
// validações básicas
if (!formData.email || !formData.password || !formData.full_name) {
Swal.fire("Campos obrigatórios", "Preencha nome, e-mail e senha.", "warning");
setSubmitting(false);
return;
}
if (formData.password.length < 6) {
Swal.fire("Senha inválida", "A senha deve ter pelo menos 6 caracteres.", "warning");
setSubmitting(false);
return;
}
if (!formData.role || formData.role.trim() === "") {
Swal.fire("Cargo obrigatório", "Selecione um cargo para o usuário.", "warning");
setSubmitting(false);
return;
}
const role = (formData.role || "").toString().trim(); // garante string exata
// payload 1: role como string (conforme docs)
const payload1 = {
email: formData.email.trim(),
password: formData.password,
full_name: formData.full_name.trim(),
phone: formData.phone || "",
cpf: formData.cpf || "",
role: role,
...(role === "paciente" && {
create_patient_record: true,
phone_mobile: formData.phone || ""
})
};
console.log("Tentando criar usuário (payload1):", payload1);
let response = await fetch(
`${supabaseUrl}/functions/v1/create-user-with-password`,
{
method: "POST",
headers: myHeaders,
body: JSON.stringify(payload1)
}
);
// tenta ler resposta (json se possível, senão texto)
let result;
try {
result = await response.json();
} catch (err) {
result = await response.text();
}
console.log("Resposta (payload1):", response.status, result);
// Se OK, finaliza
if (response.ok) {
Swal.fire({
title: "Sucesso!",
html: `
<div class="text-start">
<p><strong>Usuário criado com sucesso!</strong></p>
<p><strong>Nome:</strong> ${result.user?.full_name || formData.full_name}</p>
<p><strong>Email:</strong> ${result.user?.email || formData.email}</p>
<p><strong>Cargo:</strong> ${role}</p>
<p><strong>Telefone:</strong> ${formData.phone || "Não informado"}</p>
</div>
`,
icon: "success",
});
setShowModal(false);
setFormData({ full_name: "", email: "", phone: "", role: "secretaria", password: "" });
await fetchUsersAndRoles();
return;
}
// Se 400 e menciona role ou resposta indicar role inválida, tenta reenviar usando roles: [role]
const errMsg = typeof result === "string" ? result : JSON.stringify(result);
const mentionsRole = /role|roles|invalid role|role inválida|role not allowed/i.test(errMsg);
if ((response.status === 400 || response.status === 422) && mentionsRole) {
const payload2 = {
email: formData.email.trim(),
password: formData.password,
full_name: formData.full_name.trim(),
phone: formData.phone || "",
cpf: formData.cpf || "",
roles: [role], // tentativa alternativa
...(role === "paciente" && {
create_patient_record: true,
phone_mobile: formData.phone || ""
})
};
console.log("Servidor rejeitou role. Tentando payload2 (roles array):", payload2);
const response2 = await fetch(
`${supabaseUrl}/functions/v1/create-user-with-password`,
{
method: "POST",
headers: myHeaders,
body: JSON.stringify(payload2)
}
);
let result2;
try {
result2 = await response2.json();
} catch (err) {
result2 = await response2.text();
}
console.log("Resposta (payload2):", response2.status, result2);
if (response2.ok) {
Swal.fire("Sucesso!", result2.message || "Usuário criado com sucesso!", "success");
setShowModal(false);
setFormData({ full_name: "", email: "", phone: "", role: "secretaria", password: "" });
await fetchUsersAndRoles();
return;
} else {
// falha na segunda tentativa mostra detalhe
const detail = typeof result2 === "string" ? result2 : JSON.stringify(result2);
throw new Error(detail || "Erro ao criar usuário (tentativa com roles array falhou)");
}
}
// Se não é erro de role ou tentativas falharam, lança o erro original
throw new Error(errMsg || "Erro ao criar usuário");
} catch (err) {
console.error("Erro ao criar usuário:", err);
Swal.fire({
title: "Erro!",
text: err.message || "Falha ao criar usuário",
icon: "error",
});
} finally {
setSubmitting(false);
}
};
// Função para definir períodos e limpar datas
const handlePeriodChange = (newPeriod) => {
// Se clicar no mesmo período, limpa o filtro
if (period === newPeriod) {
setPeriod("");
} else {
setPeriod(newPeriod);
}
// Sempre limpa as datas específicas
setStartDate("");
setEndDate("");
};
const openCreateModal = () => setShowModal(true);
const closeModal = () => {
setShowModal(false);
setFormData({
full_name: "",
email: "",
phone: "",
cpf: "",
role: "secretaria",
password: ""
});
};
const filteredUsers = users.filter(p => {
if (!p) return false;
const nome = (p.full_name || "").toLowerCase();
const cpf = (p.cpf || "").toLowerCase();
const email = (p.email || "").toLowerCase();
const q = search.toLowerCase();
// Filtro por texto (nome, cpf, email)
const matchesText = nome.includes(q) || cpf.includes(q) || email.includes(q);
// Filtro por cargo
const matchesRole = !roleFilter || (p.role || "").toLowerCase().includes(roleFilter.toLowerCase());
let dateMatch = true;
if (p.created_at) {
const userDate = new Date(p.created_at);
const now = new Date();
// Filtros por período
if (period === "today") {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
dateMatch = userDate >= today && userDate < tomorrow;
} else if (period === "week") {
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - now.getDay());
weekStart.setHours(0, 0, 0, 0);
dateMatch = userDate >= weekStart;
} else if (period === "month") {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
dateMatch = userDate >= monthStart;
}
// Filtros por data específica
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999); // Inclui o dia inteiro
dateMatch = dateMatch && userDate >= start && userDate <= end;
} else if (startDate) {
const start = new Date(startDate);
dateMatch = dateMatch && userDate >= start;
} else if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateMatch = dateMatch && userDate <= end;
}
}
return matchesText && matchesRole && dateMatch;
});
const [itemsPerPage1, setItemsPerPage1] = useState(15);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastPatient = currentPage1 * itemsPerPage1;
const indexOfFirstPatient = indexOfLastPatient - itemsPerPage1;
const currentUsers = filteredUsers.slice(indexOfFirstPatient, indexOfLastPatient);
const totalPages1 = Math.ceil(filteredUsers.length / itemsPerPage1);
useEffect(() => {
setCurrentPage1(1);
}, [search, roleFilter, period, startDate, endDate]);
if (loading) return <p>Carregando usuários...</p>;
return (
<div className="page-wrapper">
<div className="content">
<div className="d-flex justify-content-between align-items-start mb-3">
<h4 className="page-title mb-0">Lista de Usuários</h4>
<button
className="btn btn-primary btn-rounded"
onClick={openCreateModal}
>
<i className="fa fa-plus"></i> Criar Usuário
</button>
</div>
{/* Todos os filtros em uma única linha */}
<div className="d-flex align-items-center mb-3" style={{ gap: "0.5rem", flexWrap: "nowrap", overflowX: "auto", height: "40px" }}>
{/* Campo de busca */}
<input
type="text"
className="form-control form-control-sm"
placeholder="🔍 Buscar usuários"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: "300px", maxWidth: "450px", }}
/>
{/* Filtro por cargo */}
<select
className="form-control form-control-sm"
style={{ minWidth: "120px", maxWidth: "140px" }}
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
>
<option value="">Todos os cargos</option>
<option value="admin">Administrador</option>
<option value="secretaria">Secretaria</option>
<option value="paciente">Paciente</option>
<option value="gestor">Gestor</option>
<option value="medico">Médico</option>
<option value="user">Usuário</option>
</select>
{/* Filtro De */}
<div className="d-flex align-items-center" style={{ gap: "0.2rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>De:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={startDate}
onChange={e => {
setStartDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Filtro Até */}
<div className="d-flex align-items-center" style={{ gap: "0.2rem" }}>
<label className="mb-0" style={{ whiteSpace: "nowrap", fontSize: "0.85rem" }}>Até:</label>
<input
type="date"
className="form-control form-control-sm"
style={{ minWidth: "130px", }}
value={endDate}
onChange={e => {
setEndDate(e.target.value);
if (e.target.value) setPeriod("");
}}
/>
</div>
{/* Botões rápidos */}
<button
className={`btn btn-sm ${period === "today" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "50px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("today")}
>
Hoje
</button>
<button
className={`btn btn-sm ${period === "week" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "55px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("week")}
>
Semana
</button>
<button
className={`btn btn-sm ${period === "month" ? "btn-primary" : "btn-outline-primary"}`}
style={{ minWidth: "45px", fontSize: "0.8rem", padding: "4px 8px" }}
onClick={() => handlePeriodChange("month")}
>
Mês
</button>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Telefone</th>
<th>Cargos</th>
<th>User ID</th>
<th>Criado em</th>
</tr>
</thead>
<tbody>
{currentUsers.length > 0 ? (
currentUsers.map((user) => (
<tr key={user.id}>
<td>{user.full_name || "-"}</td>
<td style={{ wordBreak: "break-word" }}>{user.email || "-"}</td>
<td>{user.phone || "-"}</td>
<td>
{(() => {
if (!user.role || user.role === "Sem cargo") {
return (
<span className="custom-badge status-gray" style={{ minWidth: '110px', display: 'inline-block', textAlign: 'left' }}>
<i className="fa fa-question-circle" style={{ marginRight: '6px' }}></i>
Sem cargo
</span>
);
}
const rolesArray = user.role.split(', ').map(r => r.trim());
const roleMap = {
'admin': { icon: 'fa fa-shield', label: 'Admin', color: 'status-red' },
'medico': { icon: 'fa fa-stethoscope', label: 'Médico', color: 'status-purple' },
'gestor': { icon: 'fa fa-briefcase', label: 'Gestor', color: 'status-blue' },
'secretaria': { icon: 'fa fa-phone', label: 'Secretaria', color: 'status-orange' },
'paciente': { icon: 'fa fa-user', label: 'Paciente', color: 'status-green' },
'user': { icon: 'fa fa-user-circle', label: 'User', color: 'status-pink' }
};
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{rolesArray.map((role, index) => {
const roleInfo = roleMap[role.toLowerCase()] || { icon: 'fa-question-circle', label: role, color: 'status-gray' };
return (
<span
key={index}
className={`custom-badge ${roleInfo.color}`}
style={{
minWidth: '80px',
width: '80px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.75rem',
padding: '3px 6px',
marginBottom: '2px',
textAlign: 'center'
}}
>
<i className={roleInfo.icon} style={{ marginRight: '4px' }}></i>
{roleInfo.label}
</span>
);
})}
</div>
);
})()}
</td>
<td style={{ wordBreak: "break-word", fontSize: '12px' }}>{user.id}</td>
<td>{formatDate(user.created_at)}</td>
</tr>
))
) : (
<tr>
<td colSpan="6" className="text-center">Nenhum usuário encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="d-flex flex-wrap align-items-center mt-3">
<div className="me-3 text-muted" style={{ minWidth: '140px', fontSize: '0.98em', paddingRight: '3%' }}>
Total encontrados: <b>{filteredUsers.length}</b>
</div>
<div style={{ minWidth: '140px' }}>
<select
className="form-control form-control-sm"
style={{ minWidth: "110px", maxWidth: "140px", display: 'inline-block' }}
value={itemsPerPage1}
onChange={e => {
setItemsPerPage1(Number(e.target.value));
setCurrentPage1(1);
}}
title="Itens por página"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
<option value={30}>30 por página</option>
</select>
</div>
</div>
<div className="w-100 d-flex justify-content-center mt-2">
<nav>
<ul className="pagination mb-0 justify-content-center">
{/* Primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"}
</button>
</li>
{/* Página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.max(prev - 1, 1))}>
&lt;
</button>
</li>
{/* Número da página atual */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(prev => Math.min(prev + 1, totalPages1))}>
&gt;
</button>
</li>
{/* Última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"}
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
{showModal && (
<div className="modal fade show" style={{ display: 'block', backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Criar Novo Usuário</h5>
<button type="button" className="close" onClick={closeModal}>
<span>&times;</span>
</button>
</div>
<form onSubmit={handleCreateUser}>
<div className="modal-body">
<div className="form-group">
<label>Nome Completo *</label>
<input
type="text"
className="form-control"
name="full_name"
value={formData.full_name}
onChange={handleInputChange}
required
placeholder="Digite o nome completo"
/>
</div>
<div className="form-group">
<label>Email *</label>
<input
type="email"
className="form-control"
name="email"
value={formData.email}
onChange={handleInputChange}
required
placeholder="digite@email.com"
/>
</div>
<div className="form-group">
<label>Telefone</label>
<input
type="tel"
className="form-control"
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="(11) 99999-9999"
/>
</div>
<div className="form-group">
<label>Senha *</label>
<input
type="password"
className="form-control"
name="password"
value={formData.password}
onChange={handleInputChange}
required
placeholder="Digite a senha"
/>
</div>
<div className="form-group">
<label>CPF *</label>
<input
type="text"
name="cpf"
className="form-control"
value={formData.cpf || ""}
onChange={handleInputChange}
required
/>
</div>
<div className="form-group">
<label>Cargo *</label>
<select
className="form-control"
name="role"
value={formData.role}
onChange={handleInputChange}
required
>
<option value="">Selecione um cargo</option>
<option value="secretaria">Secretária</option>
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="medico">Médico</option>
<option value="paciente">Paciente</option>
<option value="user">Usuário</option>
</select>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={closeModal}
disabled={submitting}
>
Cancelar
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
{submitting ? (
<>
<span className="spinner-border spinner-border-sm mr-2"></span>
Criando...
</>
) : (
'Criar Usuário'
)}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
export default CreateUser;

View File

@ -0,0 +1,339 @@
import React, { useEffect, useMemo, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const API_ROOT = `${supabaseUrl}/rest/v1`;
const API_URL = `${API_ROOT}/doctor_exceptions`;
const API_DOCTORS = `${API_ROOT}/doctors?select=id,full_name`;
const API_KEY = supabaseAK;
export default function Doctorexceçao() {
const token = getAccessToken();
const [exceptions, setExceptions] = useState([]);
const [doctors, setDoctors] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// ---------- CONFIGURAÇÕES COMUNS ----------
const commonHeaders = {
apikey: API_KEY,
Authorization: `Bearer ${token}`,
};
// ---------- CARREGAR DADOS ----------
const loadExceptions = async () => {
try {
setLoading(true);
setErr("");
const res = await fetch(`${API_URL}?select=*`, { headers: commonHeaders });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setExceptions(Array.isArray(data) ? data : []);
} catch (e) {
setErr(e.message || "Erro ao carregar exceções");
} finally {
setLoading(false);
}
};
const loadDoctors = async () => {
try {
const res = await fetch(API_DOCTORS, { headers: commonHeaders });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setDoctors(Array.isArray(data) ? data : []);
} catch {
setDoctors([]);
}
};
useEffect(() => {
loadDoctors();
loadExceptions();
}, [token]);
// ---------- CRIAR EXCEÇÃO ----------
const createException = async (payload) => {
try {
const body = {
...payload,
created_by: payload.created_by || payload.doctor_id,
};
const res = await fetch(API_URL, {
method: "POST",
headers: {
...commonHeaders,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
await res.json();
await loadExceptions();
Swal.fire("Sucesso!", "Exceção criada com sucesso.", "success");
} catch (e) {
Swal.fire("Erro ao criar", e.message || "Falha ao criar exceção", "error");
}
};
// ---------- DELETAR EXCEÇÃO ----------
const deleteException = async (id) => {
const confirm = await Swal.fire({
title: "Excluir exceção?",
text: "Essa ação não pode ser desfeita.",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Sim, excluir",
cancelButtonText: "Cancelar",
});
if (!confirm.isConfirmed) return;
try {
const res = await fetch(`${API_URL}?id=eq.${id}`, {
method: "DELETE",
headers: commonHeaders,
});
if (!res.ok) throw new Error(await res.text());
await loadExceptions();
Swal.fire("Removida!", "Exceção excluída com sucesso.", "success");
} catch (e) {
Swal.fire("Erro ao excluir", e.message || "Falha ao excluir", "error");
}
};
// ---------- EVENTOS DO CALENDÁRIO ----------
const events = useMemo(() => {
return exceptions.map((ex) => {
const isBlock = ex.kind === "bloqueio";
return {
id: ex.id,
title: isBlock ? "Bloqueio" : "Liberação",
start: ex.date,
allDay: true,
backgroundColor: isBlock ? "#ef4444" : "#22c55e",
borderColor: isBlock ? "#b91c1c" : "#15803d",
textColor: "#fff",
};
});
}, [exceptions]);
// ---------- HANDLERS ----------
const handleDateClick = async (info) => {
if (!doctors.length) {
Swal.fire("Sem médicos", "Cadastre médicos antes de criar exceções.", "info");
return;
}
// 1 Selecionar médico
const doctorOptions = doctors.reduce((acc, d) => {
acc[d.id] = d.full_name || d.id;
return acc;
}, {});
const s1 = await Swal.fire({
title: `Nova exceção — ${info.dateStr}`,
input: "select",
inputOptions: doctorOptions,
inputPlaceholder: "Selecione o médico",
showCancelButton: true,
confirmButtonText: "Continuar",
didOpen: (popup) => {
popup.style.position = "fixed";
popup.style.top = "230px";
}
});
if (!s1.isConfirmed || !s1.value) return;
const doctor_id = s1.value;
// 2 Tipo da exceção
const s2 = await Swal.fire({
title: "Tipo de exceção",
input: "select",
inputOptions: {
bloqueio: "Bloqueio (remover horários)",
liberacao: "Liberação (adicionar horários extras)",
},
inputPlaceholder: "Selecione o tipo",
showCancelButton: true,
confirmButtonText: "Continuar",
didOpen: (popup) => {
popup.style.position = "fixed";
popup.style.top = "230px";
}
});
if (!s2.isConfirmed || !s2.value) return;
const kind = s2.value;
// 3 Motivo
const form = await Swal.fire({
title: "Motivo (opcional)",
input: "text",
inputPlaceholder: "Ex: Congresso, folga, manutenção...",
showCancelButton: true,
confirmButtonText: "Criar exceção",
didOpen: (popup) => {
popup.style.position = "fixed";
popup.style.top = "230px";
}
});
if (!form.isConfirmed) return;
const payload = {
doctor_id,
created_by: doctor_id,
date: info.dateStr,
kind,
reason: form.value || null,
};
await createException(payload);
};
const handleEventClick = async (info) => {
const e = exceptions.find((x) => x.id === info.event.id);
if (!e) return;
await Swal.fire({
title: e.kind === "bloqueio" ? "Bloqueio" : "Liberação",
html: `<b>Médico:</b> ${
doctors.find((d) => d.id === e.doctor_id)?.full_name || e.doctor_id
}<br>
<b>Data:</b> ${e.date}<br>
<b>Motivo:</b> ${e.reason || "-"}`,
icon: "info",
showCancelButton: true,
confirmButtonText: "Excluir",
cancelButtonText: "Fechar",
}).then((r) => {
if (r.isConfirmed) deleteException(e.id);
});
};
// ---------- UI ----------
return (
<div className="content">
<div className="page-header">
<div className="row align-items-center">
<div className="col">
<h4 className="page-title">Exceções (Bloqueios / Liberações)</h4>
<span className="text-muted">
Clique numa data para adicionar exceções por médico
</span>
</div>
</div>
</div>
{/* Calendário */}
<div className="row">
<div className="col-12">
<div
className="card"
style={{
borderRadius: 10,
padding: 16,
border: "1px solid rgba(0,0,0,0.08)",
}}
>
{loading ? (
<p className="text-muted m-0">Carregando calendário</p>
) : err ? (
<p className="text-danger m-0">Erro: {err}</p>
) : (
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={ptBrLocale}
height="auto"
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,dayGridWeek,dayGridDay",
}}
events={events}
dateClick={handleDateClick}
eventClick={handleEventClick}
/>
)}
</div>
</div>
</div>
{/* Lista de exceções */}
<div className="row" style={{ marginTop: 16 }}>
<div className="col-12">
<div className="card" style={{ borderRadius: 10 }}>
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="card-title m-0">Lista de Exceções</h5>
<small className="text-muted">{exceptions.length} registro(s)</small>
</div>
<div className="card-body" style={{ paddingTop: 8 }}>
{loading ? (
<p className="text-muted m-0">Carregando lista</p>
) : err ? (
<p className="text-danger m-0">Erro: {err}</p>
) : exceptions.length === 0 ? (
<p className="text-muted m-0">Nenhuma exceção encontrada.</p>
) : (
<div className="table-responsive">
<table className="table table-border table-striped custom-table mb-0">
<thead>
<tr>
<th>Médico</th>
<th>Data</th>
<th>Tipo</th>
<th>Motivo</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{exceptions.map((ex) => (
<tr key={ex.id}>
<td>
{doctors.find((d) => d.id === ex.doctor_id)?.full_name ||
ex.doctor_id}
</td>
<td>{ex.date}</td>
<td>
{ex.kind === "bloqueio" ? (
<span className="custom-badge status-red">Bloqueio</span>
) : (
<span className="custom-badge status-green">Liberação</span>
)}
</td>
<td>{ex.reason || "-"}</td>
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => deleteException(ex.id)}
>
Excluir
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<p className="text-muted" style={{ fontSize: 12 }}>
* <b style={{ color: "#ef4444" }}>Vermelho</b> = Bloqueio,&nbsp;
<b style={{ color: "#22c55e" }}>Verde</b> = Liberação.
</p>
</div>
</div>
</div>
);
}

View File

@ -1,213 +0,0 @@
import { useState, useEffect } from "react";
import { withMask } from "use-mask-input";
import { Link } from "react-router-dom";
import "../../assets/css/index.css";
function AgendaEdit() {
const [minDate, setMinDate] = useState("");
useEffect(() => {
const getToday = () => {
const today = new Date();
const offset = today.getTimezoneOffset();
today.setMinutes(today.getMinutes() - offset);
return today.toISOString().split("T")[0];
};
setMinDate(getToday());
}, []);
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h1>Editar consulta</h1>
<hr />
<h3>Informações do paciente</h3>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>ID da consulta</label>
<input
className="form-control"
type="text"
value="APT-0001"
readOnly
/>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Nome do paciente<span className="text-danger">*</span></label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>RG</label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>CPF<span className="text-danger">*</span></label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Email do paciente</label>
<input className="form-control" type="email" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Número de telefone do paciente<span className="text-danger">*</span></label>
<input className="form-control" type="text" ref={withMask('+55 (99) 99999-9999')} />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Data de nascimento<span className="text-danger">*</span></label>
<input className="form-control" type="date" />
</div>
</div>
<div className="form-group gender-select col-md-6">
<label className="gen-label">Sexo:<span className="text-danger">*</span></label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Outro
</label>
</div>
</div>
</div>
<hr />
<h3>Informações do atendimento</h3>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Especialidade<span className="text-danger">*</span></label>
<select className="select form-control">
<option>Selecione</option>
<option>Cardiologia</option>
<option>Pediatria</option>
<option>Dermatologia</option>
<option>Ginecologia</option>
<option>Neurologia</option>
<option>Psiquiatria</option>
<option>Ortopedia</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Médico<span className="text-danger">*</span></label>
<select className="select form-control">
<option>Selecione</option>
<option>Davi Andrade</option>
<option>Caio Pereira</option>
<option>Paulo Lucas</option>
</select>
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Data<span className="text-danger">*</span></label>
<div>
<input
type="date"
className="form-control"
min={minDate}
/>
</div>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Horas<span className="text-danger">*</span></label>
<div>
<input type="time" className="form-control" />
</div>
</div>
</div>
</div>
<div className="form-group">
<label>Observação</label>
<textarea cols="30" rows="4" className="form-control"></textarea>
</div>
<div className="form-group">
<label className="display-block">Status da consulta</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_active"
value="option1"
defaultChecked
/>
<label
className="form-check-label"
htmlFor="product_active"
>
Ativo
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_inactive"
value="option2"
/>
<label
className="form-check-label"
htmlFor="product_inactive"
>
Inativo
</label>
</div>
</div>
<div className="m-t-20 text-center">
<Link to="/agendalist">
<button className="btn btn-primary submit-btn" type="button">
Salvar
</button>
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default AgendaEdit;

View File

@ -1,213 +0,0 @@
import { useState, useEffect } from "react";
import { withMask } from "use-mask-input";
import { Link } from "react-router-dom";
import "../../assets/css/index.css";
function AgendaForm() {
const [minDate, setMinDate] = useState("");
useEffect(() => {
const getToday = () => {
const today = new Date();
const offset = today.getTimezoneOffset();
today.setMinutes(today.getMinutes() - offset);
return today.toISOString().split("T")[0];
};
setMinDate(getToday());
}, []);
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h1>Nova consulta</h1>
<hr />
<h3>Informações do paciente</h3>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>ID da consulta</label>
<input
className="form-control"
type="text"
value="APT-0001"
readOnly
/>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Nome do paciente<span className="text-danger">*</span></label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>RG</label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>CPF<span className="text-danger">*</span></label>
<input type="text" className="form-control" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Email do paciente</label>
<input className="form-control" type="email" />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Número de telefone do paciente<span className="text-danger">*</span></label>
<input className="form-control" type="text" ref={withMask('+55 (99) 99999-9999')} />
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Data de nascimento<span className="text-danger">*</span></label>
<input className="form-control" type="date" />
</div>
</div>
<div className="form-group gender-select col-md-6">
<label className="gen-label">Sexo:<span className="text-danger">*</span></label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
/> Outro
</label>
</div>
</div>
</div>
<hr />
<h3>Informações do atendimento</h3>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Especialidade<span className="text-danger">*</span></label>
<select className="select form-control">
<option>Selecione</option>
<option>Cardiologia</option>
<option>Pediatria</option>
<option>Dermatologia</option>
<option>Ginecologia</option>
<option>Neurologia</option>
<option>Psiquiatria</option>
<option>Ortopedia</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Médico<span className="text-danger">*</span></label>
<select className="select form-control">
<option>Selecione</option>
<option>Davi Andrade</option>
<option>Caio Pereira</option>
<option>Paulo Lucas</option>
</select>
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Data<span className="text-danger">*</span></label>
<div>
<input
type="date"
className="form-control"
min={minDate}
/>
</div>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Horas<span className="text-danger">*</span></label>
<div>
<input type="time" className="form-control" />
</div>
</div>
</div>
</div>
<div className="form-group">
<label>Observação</label>
<textarea cols="30" rows="4" className="form-control"></textarea>
</div>
<div className="form-group">
<label className="display-block">Status da consulta</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_active"
value="option1"
defaultChecked
/>
<label
className="form-check-label"
htmlFor="product_active"
>
Ativo
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_inactive"
value="option2"
/>
<label
className="form-check-label"
htmlFor="product_inactive"
>
Inativo
</label>
</div>
</div>
<div className="m-t-20 text-center">
<Link to="/agendalist">
<button className="btn btn-primary submit-btn" type="button">
Criar consulta
</button>
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default AgendaForm;

View File

@ -1,231 +0,0 @@
import "../../assets/css/index.css"
import { Link } from "react-router-dom";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
// Posiciona o menu após renderar (medir tamanho do menu)
useLayoutEffect(() => {
if (!isOpen) return;
if (!anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// tenta alinhar à direita do botão (como dropdown-menu-right)
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
// evita sair da esquerda da tela
if (left < 0) left = scrollX + 4;
// se extrapolar bottom, abre para cima
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
// fecha ao clicar fora / ao rolar
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
const menu = menuRef.current;
if (menu && !menu.contains(e.target) && anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
// captura scroll em qualquer elemento (true)
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className} // mantém as classes que você já usa no CSS
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function AgendaList() {
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({});
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-sm-4 col-3">
<h4 className="page-title">Lista de consultas</h4>
<input
type="text"
className="form-control"
placeholder="🔍 Buscar consulta"
style={{ minWidth: "200px" }}
/>
<br />
</div>
<div className="col-sm-8 col-9 text-right m-b-20">
<Link to="/agendaform" className="btn btn-primary btn-rounded">
<i className="fa fa-plus"></i> Adicionar consulta
</Link>
</div>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-striped custom-table">
<thead>
<tr>
<th>ID da cosulta</th>
<th>Nome do Paciente</th>
<th>Idade</th>
<th>Nome do médico</th>
<th>Especialidade</th>
<th>Data da consulta</th>
<th>Hora da consulta</th>
<th>Status</th>
<th className="text-right">Ação</th>
</tr>
</thead>
<tbody>
<tr>
<td>APT0001</td>
<td>João Miguel</td>
<td>18</td>
<td>Davi Andrade</td>
<td>Cardiologista</td>
<td>25 Set 2025</td>
<td>10:00am - 11:00am</td>
<td>
<span className="custom-badge status-green">
Ativo
</span>
</td>
<td className="text-right">
<div className="dropdown dropdown-action" style={{ display: "inline-block" }}>
<button
type="button"
ref={(el) => (anchorRefs.current["menu"] = el)}
className="action-icon"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === "menu" ? null : "menu");
}}
>
<i className="fa fa-ellipsis-v"></i>
</button>
<DropdownPortal
anchorEl={anchorRefs.current["menu"]}
isOpen={openDropdown === "menu"}
onClose={() => setOpenDropdown(null)}
className="dropdown-menu dropdown-menu-right show"
>
{/*<Link
className="dropdown-item-custom"
to={`/profilepatient`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
>
<i className="fa fa-eye"></i> Ver Detalhes
</Link>*/}
<Link
className="dropdown-item-custom"
to={`/agendaedit`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
>
<i className="fa fa-pencil m-r-5"></i> Editar
</Link>
<button
className="dropdown-item-custom dropdown-item-delete"
onClick={() => handleDelete()}
>
<i className="fa fa-trash-o m-r-5"></i> Excluir
</button>
</DropdownPortal>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Modal delete */}
<div
id="delete_appointment"
className="modal fade delete-modal"
role="dialog"
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-body text-center">
<img src="assets/img/sent.png" alt="" width="50" height="46" />
<h3>Are you sure want to delete this Appointment?</h3>
<div className="m-t-20">
<a
href="#"
className="btn btn-white"
data-dismiss="modal"
>
Close
</a>
<button type="submit" className="btn btn-danger">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default AgendaList;

View File

@ -1,422 +0,0 @@
import "../../assets/css/index.css"
import { withMask } from "use-mask-input";
import { useState } from "react";
import supabase from "../../Supabase"
import { Link } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useEffect } from "react";
function EditDoctor() {
const [doctors, setdoctors] = useState([]);
const {id} = useParams()
useEffect(() => {
const fetchDoctors = async () => {
const {data, error} = await supabase
.from('Doctor')
.select('*')
.eq ('id', id)
.single()
if(error){
console.error("Erro ao buscar pacientes:", error);
}else{
setdoctors(data);
}
};
fetchDoctors();
} , []);
const handleChange = (e) => {
const { name, value } = e.target;
setdoctors((prev) => ({
...prev,
[name]: value
}));
}
const handleEdit = async (e) => {
const { data, error } = await supabase
.from("Doctor")
.update([doctors])
.eq ('id', id)
.single()
};
const buscarCep = (e) => {
const cep = doctors.cep.replace(/\D/g, '');
console.log(cep);
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then(response => response.json())
.then(data => {
console.log(data)
// salvando os valores para depois colocar nos inputs
setValuesFromCep(data)
// estou salvando os valoeres no patientData
setdoctors((prev) => ({
...prev,
cidade: data.localidade || '',
estado: data.estado || '',
logradouro: data.logradouro || "",
bairro: data.bairro || '',
}));
})
}
const setValuesFromCep = (data) => {
document.getElementById('cidade').value = data.localidade || '';
document.getElementById('estado').value = data.uf || '';
document.getElementById('logradouro').value= data.logradouro || '';
document.getElementById('bairro').value= data.bairro || '';
}
return (
<div className="main-wrapper">
{/* FORMULÁRIO*/}
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h4 className="page-title">Editar Médico</h4>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label>
Nome <span className="text-danger">*</span>
</label>
<input className="form-control" type="text"
name="nome"
value={doctors.nome}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Sobrenome</label>
<input className="form-control" type="text"
name="sobrenome"
value={doctors.sobrenome}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>CPF <span className="text-danger">*</span></label>
<input className="form-control" type="text" ref={withMask('cpf')}
name="cpf"
value={doctors.cpf}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>CRM<span className="text-danger">*</span></label>
<input className="form-control" type="text"
name="crm"
value={doctors.crm}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<label>Especialidade</label>
<select
name="especialidade"
id="especialidade"
className="form-control"
value={doctors.especialidade}
onChange={handleChange}
>
<option value="">Selecionar</option>
<option value="cardiologista">Cardiologista</option>
<option value="Pediatria">Pediatria</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Ginecologia">Ginecologia</option>
<option value="Neurologia">Neurologia</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Ortopedia">Ortopedia</option>
</select>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Senha <span className="text-danger">*</span></label>
<input className="form-control" type="password"
name="senha"
value={doctors.senha}
onChange={handleChange} />
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Email</label>
<input className="form-control" type="email" ref={withMask('email')}
name="email"
value={doctors.email}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Confirmar Senha</label>
<input className="form-control" type="password"
name="confirmarSenha"
value={doctors.confirmarSenha}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Data de Nascimento</label>
<div className="">
<input type="date" className="form-control"
name="data_nascimento"
value={doctors.data_nascimento}
onChange={handleChange}
/>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Telefone </label>
<input className="form-control" type="text" ref={withMask('+99 (99)99999-9999')}
name="telefone"
value={doctors.telefone}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group gender-select">
<label className="gen-label">Sexo:</label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Masculino"}
checked={doctors.sexo === "Masculino"}
onChange={handleChange}
/>Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Feminino"}
checked={doctors.sexo === "Feminino"}
onChange={handleChange}
/>Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Outro"}
checked={doctors.sexo === "Outro"}
onChange={handleChange}
/>Outro
</label>
</div>
</div>
</div>
<div className="col-sm-12">
<hr />
<h2>Endereço</h2>
</div>
<div className="col-sm-12">
<div className="row">
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>CEP</label>
<input type="text" className="form-control "
id="cep"
name="cep"
value={doctors.cep}
onChange={handleChange}
onBlur={buscarCep}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Bairro</label>
<input type="text" className="form-control "
id="bairro"
name="bairro"
value={doctors.bairro}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Referência</label>
<input type="text" className="form-control "
id="referencia"
name="referencia"
Referência
value={doctors.referencia}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Logradouro</label>
<input type="text" className="form-control "
id="logradouro"
name="logradouro"
Referência
value={doctors.logradouro}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Complemento</label>
<input type="text" className="form-control "
id="complemento"
name="complemento"
Referência
value={doctors.complemento}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Cidade</label>
<input type="text" className="form-control"
id="cidade"
name="cidade"
value={doctors.cidade}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Estado</label>
<input type="text" className="form-control"
id="estado"
name="estado"
value={doctors.estado}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Número</label>
<input type="text" className="form-control"
id="numero"
name="numero"
value={doctors.numero}
onChange={handleChange}
/>
</div>
</div>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Anexo</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src="assets/img/user.jpg" />
</div>
<div className="upload-input">
<input type="file" multiple accept="image/png, image/jpeg" className="form-control" />
</div>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Foto</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src="assets/img/user.jpg" />
</div>
<div className="upload-input">
<input type="file" accept="image/png, image/jpeg" className="form-control" />
</div>
</div>
</div>
</div>
<div className="form-group">
<label>Biografia</label>
<textarea
className="form-control"
rows="3"
cols="30"
name="biografia"
value={doctors.biografia}
onChange={handleChange}
></textarea>
</div>
<div className="form-group">
<label className="display-block">Status</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="status"
value="ativo"
checked={doctors.status === "ativo"}
onChange={handleChange}
/>
<label
className="form-check-label"
htmlFor="doctor_active"
>
Ativo
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="status"
value="inativo"
checked={doctors.status === "inativo"}
onChange={handleChange}
/>
<label
className="form-check-label"
htmlFor="doctor_inactive"
>
Inativo
</label>
</div>
</div>
<div className="m-t-20 text-center">
<Link to="/doctorlist"><button
className="btn btn-primary submit-btn"
onClick={handleEdit}>
Editar
</button></Link>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default EditDoctor

View File

@ -1,448 +0,0 @@
import "../../assets/css/index.css"
import { withMask } from "use-mask-input";
import { useState } from "react";
import supabase from "../../Supabase"
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
function DoctorForm() {
const [doctorData, setdoctorData] = useState({
nome: "",
sobrenome: "",
cpf: "",
crm: "",
senha: "",
confirmarsenha: "",
email: "",
data_nascimento: "",
telefone: "",
sexo: "",
endereco: "",
numero: "",
cidade: "",
estado: "",
cep: "",
biografia: "",
status: "inativo",
especialidade: "",
bairro:"",
referencia:"",
logradouro:"",
complemento:""
});
const handleChange = (e) => {
const { name, value } = e.target;
setdoctorData((prev) => ({
...prev,
[name]: value
}));
}
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
const requiredFields = ["nome","cpf","crm","senha","confirmarsenha","data_nascimento","sexo","cep","logradouro","numero","bairro","cidade","estado","especialidade","email","telefone","data_nascimento"];
const missing = requiredFields.filter(f => !doctorData[f] || doctorData[f].toString().trim() === "");
if (missing.length > 0) {
alert("Preencha todos os campos obrigatórios.");
return;
}
// Verificar se senha e confirmarSenha são iguais
if (doctorData.senha !== doctorData.confirmarsenha) {
alert("Senha e Confirmar Senha não coincidem.");
return;
}
const { data, error } = await supabase
.from("Doctor")
.insert([doctorData]);
if (error) {
console.error("Erro ao cadastrar doutor:", error);
alert(`Erro ao cadastrar doutor: ${error.message}`);
} else {
alert("Doutor cadastrado com sucesso!");
navigate("/doctorlist");
}
};
const buscarCep = (e) => {
const cep = doctorData.cep.replace(/\D/g, '');
console.log(cep);
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then(response => response.json())
.then(data => {
console.log(data)
// salvando os valores para depois colocar nos inputs
setValuesFromCep(data)
// estou salvando os valoeres no patientData
setdoctorData((prev) => ({
...prev,
cidade: data.localidade || '',
estado: data.estado || '',
logradouro: data.logradouro || "",
bairro: data.bairro || '',
}));
})
}
const setValuesFromCep = (data) => {
document.getElementById('cidade').value = data.localidade || '';
document.getElementById('estado').value = data.uf || '';
document.getElementById('logradouro').value= data.logradouro || '';
document.getElementById('bairro').value= data.bairro || '';
}
return (
<div className="main-wrapper">
{/* FORMULÁRIO*/}
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h4 className="page-title">Cadastrar Médico</h4>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label>
Nome <span className="text-danger">*</span>
</label>
<input className="form-control" type="text"
name="nome"
value={doctorData.nome}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Sobrenome</label>
<input className="form-control" type="text"
name="sobrenome"
value={doctorData.sobrenome}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>CPF<span className="text-danger">*</span></label>
<input className="form-control" type="text" ref={withMask('cpf')}
name="cpf"
value={doctorData.cpf}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>CRM<span className="text-danger">*</span></label>
<input className="form-control" type="text"
name="crm"
value={doctorData.crm}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<label>Especialidade<span className="text-danger">*</span></label>
<select
name="especialidade"
id="especialidade"
className="form-control"
value={doctorData.especialidade}
onChange={handleChange}
>
<option value="">Selecionar</option>
<option value="cardiologista">Cardiologista</option>
<option value="Pediatria">Pediatria</option>
<option value="Dermatologia">Dermatologia</option>
<option value="Ginecologia">Ginecologia</option>
<option value="Neurologia">Neurologia</option>
<option value="Psiquiatria">Psiquiatria</option>
<option value="Ortopedia">Ortopedia</option>
</select>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Senha <span className="text-danger">*</span></label>
<input className="form-control" type="password"
name="senha"
value={doctorData.senha}
onChange={handleChange} />
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Email<span className="text-danger">*</span></label>
<input className="form-control" type="email" ref={withMask('email')}
name="email"
value={doctorData.email}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Confirmar Senha<span className="text-danger">*</span></label>
<input className="form-control" type="password"
name="confirmarsenha"
value={doctorData.confirmarsenha}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Data de Nascimento<span className="text-danger">*</span></label>
<div className="">
<input type="date" className="form-control"
name="data_nascimento"
value={doctorData.data_nascimento}
onChange={handleChange}
/>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Telefone<span className="text-danger">*</span></label>
<input className="form-control" type="text" ref={withMask('+99 (99)99999-9999')}
name="telefone"
value={doctorData.telefone}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group gender-select">
<label className="gen-label">Sexo:<span className="text-danger">*</span></label>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Masculino"}
checked={doctorData.sexo === "Masculino"}
onChange={handleChange}
/>Masculino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Feminino"}
checked={doctorData.sexo === "Feminino"}
onChange={handleChange}
/>Feminino
</label>
</div>
<div className="form-check-inline">
<label className="form-check-label">
<input type="radio" name="sexo" className="form-check-input"
value={"Outro"}
checked={doctorData.sexo === "Outro"}
onChange={handleChange}
/>Outro
</label>
</div>
</div>
</div>
<div className="col-sm-12">
<hr />
<h2>Endereço</h2>
</div>
<div className="col-sm-12">
<div className="row">
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>CEP<span className="text-danger">*</span></label>
<input type="text" className="form-control "
id="cep"
name="cep"
value={doctorData.cep}
onChange={handleChange}
onBlur={buscarCep}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Bairro<span className="text-danger">*</span></label>
<input type="text" className="form-control "
id="bairro"
name="bairro"
value={doctorData.bairro}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Referência</label>
<input type="text" className="form-control "
id="referencia"
name="referencia"
Referência
value={doctorData.referencia}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Logradouro<span className="text-danger">*</span></label>
<input type="text" className="form-control "
id="logradouro"
name="logradouro"
Referência
value={doctorData.logradouro}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Complemento</label>
<input type="text" className="form-control "
id="complemento"
name="complemento"
Referência
value={doctorData.complemento}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Cidade<span className="text-danger">*</span></label>
<input type="text" className="form-control"
id="cidade"
name="cidade"
value={doctorData.cidade}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Estado<span className="text-danger">*</span></label>
<input type="text" className="form-control"
id="estado"
name="estado"
value={doctorData.estado}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-6 col-lg-3">
<div className="form-group">
<label>Número<span className="text-danger">*</span></label>
<input type="text" className="form-control"
id="numero"
name="numero"
value={doctorData.numero}
onChange={handleChange}
/>
</div>
</div>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Anexo</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src="assets/img/user.jpg" />
</div>
<div className="upload-input">
<input type="file" multiple accept="image/png, image/jpeg" className="form-control" />
</div>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label>Foto</label>
<div className="profile-upload">
<div className="upload-img">
<img alt="" src="assets/img/user.jpg" />
</div>
<div className="upload-input">
<input type="file" accept="image/png, image/jpeg" className="form-control" />
</div>
</div>
</div>
</div>
<div className="form-group">
<label>Biografia</label>
<textarea
className="form-control"
rows="3"
cols="30"
name="biografia"
value={doctorData.biografia}
onChange={handleChange}
></textarea>
</div>
<div className="form-group">
<label className="display-block">Status</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="status"
value="ativo"
checked={doctorData.status === "ativo"}
onChange={handleChange}
/>
<label
className="form-check-label"
htmlFor="doctor_active"
>
Ativo
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="status"
value="inativo"
checked={doctorData.status === "inativo"}
onChange={handleChange}
/>
<label
className="form-check-label"
htmlFor="doctor_inactive"
>
Inativo
</label>
</div>
</div>
<div className="m-t-20 text-center">
<button
className="btn btn-primary submit-btn"
type="submit"
>
Cadastrar Médico
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default DoctorForm

View File

@ -1,142 +0,0 @@
import "../../assets/css/index.css";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import supabase from "../../Supabase";
function Doctors() {
const [doctors, setDoctors] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
useEffect(() => {
const fetchDoctors = async () => {
const { data, error } = await supabase
.from("Doctor")
.select("*");
if (error) {
console.error("Erro ao buscar pacientes:", error);
} else {
setDoctors(data);
}
};
fetchDoctors();
}, []);
const handleDelete = async (id) => {
if (window.confirm("Tem certeza que deseja excluir este médico?")) {
const { error } = await supabase.from("Doctor").delete().eq("id", id);
if (error) console.error("Erro ao deletar médico:", error);
else setDoctors(doctors.filter((doc) => doc.id !== id));
}
};
return (
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-sm-4 col-3">
<h4 className="page-title">Médicos</h4>
</div>
<div className="col-sm-8 col-9 text-right m-b-20">
<Link
to="/doctorform"
className="btn btn-primary btn-rounded float-right"
>
<i className="fa fa-plus"></i> Adicionar Médico
</Link>
</div>
</div>
<div className="row doctor-grid">
{doctors.map((doctor) => (
<div key={doctor.id} className="col-md-4 col-sm-4 col-lg-3">
<div className="profile-widget">
<div className="doctor-img">
<div className="avatar">
<img alt="" src="/img/doctor-thumb-03.jpg" />
</div>
</div>
{/* Dropdown estilizado */}
<div className="dropdown profile-action">
<button
type="button"
className="action-icon"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === doctor.id ? null : doctor.id);
}}
>
<i className="fa fa-ellipsis-v"></i>
</button>
{openDropdown === doctor.id && (
<div
className="dropdown-menu dropdown-menu-right show"
style={{ position: "absolute", zIndex: 1000 }}
>
{/* Ver Detalhes */}
<Link
className="dropdown-item-custom"
to={`/profiledoctor/${doctor.id}`}
onClick={(e) => e.stopPropagation()}
>
<i className="fa fa-eye"></i> Ver Detalhes
</Link>
{/* Edit */}
<Link
className="dropdown-item-custom"
to={`/editdoctor/${doctor.id}`}
>
<i className="fa fa-pencil m-r-5"></i> Editar
</Link>
{/* Delete */}
<button
className="dropdown-item-custom dropdown-item-delete"
onClick={() => handleDelete(doctor.id)}
>
<i className="fa fa-trash-o m-r-5"></i> Delete
</button>
</div>
)}
</div>
<h4 className="doctor-name text-ellipsis">
<Link to={`/profiledoctor/${doctor.id}`}>
{doctor.nome} {doctor.sobrenome}
</Link>
</h4>
<div className="doc-prof">{doctor.especialidade}</div>
<div className="user-country">
<i className="fa fa-map-marker"></i> {doctor.cidade}
</div>
</div>
</div>
))}
</div>
</div>
{/* Modal delete (não alterado) */}
<div id="delete_doctor" className="modal fade delete-modal" role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-body text-center">
<img src="assets/img/sent.png" alt="" width="50" height="46" />
<h3>Are you sure want to delete this Doctor?</h3>
<div className="m-t-20">
<a href="#" className="btn btn-white" data-dismiss="modal">
Close
</a>
<button type="submit" className="btn btn-danger">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Doctors;

View File

@ -1,127 +0,0 @@
import "../../assets/css/index.css";
import supabase from "../../Supabase";
import { useState } from "react";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
function DoctorProfile() {
const [doctorData, setdoctorData] = useState([]);
const {id} = useParams()
useEffect(() => {
const fetchDoctors = async () => {
const {data, error} = await supabase
.from('Doctor')
.select('*')
.eq ('id', id)
.single()
if(error){
console.error("Erro ao buscar pacientes:", error);
}else{
setdoctorData(data);
}
};
fetchDoctors();
} , []);
return (
<div className="main-wrapper">
{/* Page Content */}
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-sm-7 col-6">
<h4 className="page-title">Perfil Médico</h4>
</div>
</div>
{/* Profile Header */}
<div className="card-box profile-header">
<div className="row">
<div className="col-md-12">
<div className="profile-view">
<div className="profile-img-wrap">
<div className="profile-img">
<a href="#">
<img src="/img/doctor-thumb-03.jpg" />
</a>
</div>
</div>
<div className="profile-basic">
<div className="row">
<div className="col-md-5">
<div className="profile-info-left">
<h3 className="user-name m-t-0 mb-0">{doctorData.nome} {doctorData.sobrenome}</h3>
<a className="text">{doctorData.especialidade}</a>
<div className="staff-id"></div>
</div>
</div>
<div className="col-md-7">
<ul className="personal-info">
<li>
<span className="title">Phone:</span>
<span className="text"><a href="#">{doctorData.telefone}</a></span>
</li>
<li>
<span className="title">Email:</span>
<span className="text"><a href="#">{doctorData.email}</a></span>
</li>
<li>
<span className="title">Data de nascimento:</span>
<span className="text">{doctorData.data_nascimento}</span>
</li>
<li>
<span className="title">Região</span>
<span className="text">{doctorData.cidade}, {doctorData.estado}, Brasil</span>
</li>
<li>
<span className="title">Sexo</span>
<span className="text">{doctorData.sexo}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="profile-tabs">
<ul className="nav nav-tabs nav-tabs-bottom">
<li className="nav-item">
<a className="nav-link active" href="#about-cont" data-toggle="tab">Sobre</a>
</li>
</ul>
<div className="tab-content">
<div className="tab-pane show active" id="about-cont">
<div className="row">
<div className="col-md-12">
<div className="card-box">
<h3 className="card-title">Biografia</h3>
<div className="experience-box">
<div className="experience-content">
<p>{doctorData.biografia}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default DoctorProfile

View File

@ -1,100 +1,97 @@
import { Outlet, NavLink } from "react-router-dom";
import "../../assets/css/index.css";
import { Outlet, NavLink, useLocation } from "react-router-dom";
import './../../assets/css/index.css'
import Navbar from '../../components/layouts/Navbar'
import { useState } from "react";
import Chatbox from '../../components/chat/Chatbox';
import AccessibilityWidget from '../../components/AccessibilityWidget';
import { Link } from "react-router-dom";
import { useResponsive } from '../../utils/useResponsive';
import { getAccessToken } from '../../utils/auth.js';
import { getUserRole } from '../../utils/userInfo.js';
import { Navigate } from 'react-router-dom';
import Sidebar from "../../components/layouts/Sidebar.jsx";
function DoctorApp() {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// 2. Adicione a função para alternar o estado
const toggleSidebar = () => {
setSidebarOpen(!isSidebarOpen);
};
// 3. Crie a string de classe que será aplicada dinamicamente
const mainWrapperClass = isSidebarOpen ? 'main-wrapper sidebar-open' : 'main-wrapper';
// Função para verificar se a rota está ativa
const isActive = (path) => {
const currentPath = location.pathname;
// Verificação exata primeiro
if (currentPath === path) return true;
// Verificação de subrotas (ex: /doctor/patients/edit/123)
if (currentPath.startsWith(path + '/')) return true;
// Verificações específicas para páginas de edição/criação
if (path === '/doctor/patients' && (
currentPath.includes('/doctor/editpatient/') ||
currentPath.includes('/doctor/patientform') ||
currentPath.includes('/doctor/patient/')
)) return true;
if (path === '/doctor/prontuariolist' && (
currentPath.includes('/doctor/prontuario/') ||
currentPath.includes('/doctor/editprontuario/') ||
currentPath.includes('/doctor/prontuarioform')
)) return true;
if (path === '/doctor/consultas' && (
currentPath.includes('/doctor/consulta/') ||
currentPath.includes('/doctor/editconsulta/') ||
currentPath.includes('/doctor/consultaform')
)) return true;
if (path === '/doctor/laudolist' && (
currentPath.includes('/doctor/laudo/') ||
currentPath.includes('/doctor/editlaudo/') ||
currentPath.includes('/doctor/laudoform')
)) return true;
return false;
};
const token = getAccessToken();
const user = getUserRole();
// Verificação de autenticação
if (!token) {
return <Navigate to="/login" replace />;
}
// Verificação de role
if (user !== 'medico') {
return (
<div className="page-wrapper">
<div className="content">
<div className="alert alert-danger text-center">
<h4> Acesso Negado</h4>
<p>Apenas médicos podem acessar esta área.</p>
<button
className="btn btn-primary"
onClick={() => window.history.back()}
>
Voltar
</button>
</div>
</div>
</div>
);
}
return (
<div className="main-wrapper">
{/* Header */}
<div className="header">
<div className="header-left">
<a href="/" className="logo">
<img src="/img/logomedconnect.png" width="35" height="35" alt="" />
<span>MediConnect</span>
</a>
</div>
<a id="mobile_btn" className="mobile_btn float-left" href="#sidebar">
<i className="fa fa-bars"></i>
</a>
<ul className="nav user-menu float-right">
<li className="nav-item dropdown has-arrow">
<a
href="#!"
className="dropdown-toggle nav-link user-link"
data-toggle="dropdown"
>
<span className="user-img">
<span className="status online"></span>
</span>
<span>Médico</span>
</a>
<div className="dropdown-menu">
<a className="dropdown-item" href="#profile">
Meu Perfil
</a>
<a className="dropdown-item" href="#settings">
Configurações
</a>
<a className="dropdown-item" href="#logout">
Sair
</a>
</div>
</li>
</ul>
</div>
{/* Sidebar */}
<div className="sidebar" id="sidebar">
<div className="sidebar-inner slimscroll">
<div id="sidebar-menu" className="sidebar-menu">
<ul>
<li className="menu-title">
<span>Painel do Médico</span>
</li>
<li>
<NavLink
to="/doctor/patients"
className={({ isActive }) => (isActive ? "active" : "")}
>
<i className="fa fa-users"></i>
<span>Pacientes</span>
</NavLink>
</li>
<li>
<NavLink
to="/doctor/calendar"
className={({ isActive }) => (isActive ? "active" : "")}
>
<i className="fa fa-calendar"></i>
<span>Calendário</span>
</NavLink>
</li>
<li>
<NavLink
to="/doctor/dashboard"
className={({ isActive }) => (isActive ? "active" : "")}
>
<i className="fa fa-bar-chart"></i>
<span>Dashboard</span>
</NavLink>
</li>
</ul>
</div>
</div>
</div>
{/* Conteúdo */}
<div className="page-wrapper">
<div className="content">
<div>
<Sidebar />
<Outlet />
</div>
</div>
</div>
);
}
export default DoctorApp;
export default DoctorApp;

View File

@ -1,55 +1,256 @@
// --- SEU JSX COMPLETO ---
import React, { useState, useEffect } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import "../../assets/css/index.css";
import { getAccessToken } from '../../utils/auth';
import { getDoctorId } from "../../utils/userInfo";
function DoctorCalendar() {
return (
<div className="doctor-calendar-container">
<h2 className="calendar-title">Calendário do Médico</h2>
<div className="calendar-wrapper">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={"dayGridMonth"}
locale={ptBrLocale}
headerToolbar={{
start: "today prev,next",
center: "title",
end: "dayGridMonth,timeGridWeek,timeGridDay",
}}
height="auto"
/>
// Função para formatar data/hora igual ao ConsultaList
function formatDateTime(dateString) {
if (!dateString) return '';
try {
const [datePart, timePart] = dateString.split('T');
const [year, month, day] = datePart.split('-');
const [hour, minute] = timePart.split(':');
return `${day}/${month}/${year} ${hour}:${minute}`;
} catch {
return dateString;
}
}
export default function DoctorCalendar() {
const [events, setEvents] = useState([]);
const [editingEvent, setEditingEvent] = useState(null);
const [showPopup, setShowPopup] = useState(false);
const [showActionModal, setShowActionModal] = useState(false);
const [step, setStep] = useState(1);
const [newEvent, setNewEvent] = useState({ title: "", time: "" });
const [selectedDate, setSelectedDate] = useState(null);
const [selectedEvent, setSelectedEvent] = useState(null);
const [pacientesMap, setPacientesMap] = useState({});
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const colorsByType = {
presencial: "#4dabf7",
online: "#f76c6c",
Rotina: "#4dabf7",
Cardiologia: "#f76c6c",
Otorrino: "#f7b84d",
Pediatria: "#6cf78b"
};
const doctor_id = getDoctorId();
const tokenUsuario = getAccessToken();
useEffect(() => {
const fetchAppointments = async () => {
try {
const requestOptions = {
method: "GET",
headers: { apikey: supabaseAK, Authorization: `Bearer ${tokenUsuario}` },
};
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments?doctor_id=eq.${doctor_id}`, requestOptions
);
const result = await response.json();
const consultas = Array.isArray(result) ? result : [];
const idsUnicos = [...new Set(consultas.map((c) => c.patient_id))];
const promises = idsUnicos.map(async (id) => {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/patients?id=eq.${id}`,
{
method: "GET",
headers: { apikey: supabaseAK, Authorization: `Bearer ${tokenUsuario}` },
}
);
const data = await res.json();
return { id, full_name: data?.[0]?.full_name || "Nome não encontrado" };
} catch {
return { id, full_name: "Nome não encontrado" };
}
});
const pacientes = await Promise.all(promises);
const map = {};
pacientes.forEach((p) => (map[p.id] = p.full_name));
setPacientesMap(map);
const calendarEvents = consultas.map((consulta) => {
const [date, timeFull] = consulta.scheduled_at.split('T');
const time = timeFull ? timeFull.substring(0, 5) : '';
return {
id: consulta.id,
title: map[consulta.patient_id] || "Paciente",
date: date,
time: time,
start: `${date}T${time}:00`,
type: consulta.appointment_type || "presencial",
color: colorsByType[consulta.appointment_type] || "#4dabf7",
appointmentData: consulta,
};
});
setEvents(calendarEvents);
} catch (error) {
console.error("Erro ao buscar consultas:", error);
}
};
if (doctor_id) fetchAppointments();
}, [doctor_id, tokenUsuario]);
const handleDateClick = (arg) => {
setSelectedDate(arg.dateStr);
setNewEvent({ title: "", time: "" });
setStep(1);
setEditingEvent(null);
setShowPopup(true);
};
const handleAddEvent = () => {
const eventToAdd = {
id: Date.now(),
title: newEvent.title,
time: newEvent.time,
date: selectedDate,
start: `${selectedDate}T${newEvent.time}:00`,
color: colorsByType[newEvent.type] || "#4dabf7"
};
setEvents((prev) => [...prev, eventToAdd]);
setShowPopup(false);
};
const handleEditEvent = () => {
setEvents((prevEvents) =>
prevEvents.map((ev) =>
ev.id.toString() === editingEvent.id.toString()
? {
...ev,
title: newEvent.title,
time: newEvent.time,
start: `${ev.date}T${newEvent.time}:00`,
color: colorsByType[newEvent.type] || "#4dabf7"
}
: ev
)
);
setEditingEvent(null);
setShowPopup(false);
setShowActionModal(false);
};
const handleNextStep = () => {
if (step < 2) setStep(step + 1);
else editingEvent ? handleEditEvent() : handleAddEvent();
};
const handleEventClick = (clickInfo) => {
setSelectedEvent(clickInfo.event);
setShowActionModal(true);
};
const renderEventContent = (eventInfo) => {
const bg =
eventInfo.event.backgroundColor ||
eventInfo.event.extendedProps?.color ||
"#4dabf7";
const appointmentType = eventInfo.event.extendedProps?.type || "presencial";
const typeLabel = appointmentType === "presencial" ? "Presencial" : "Online";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: "0.72rem",
padding: "1px 6px",
lineHeight: 1.1,
borderRadius: 4,
backgroundColor: bg,
color: "#fff",
cursor: "pointer",
maxWidth: "100%",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}}
title={`${eventInfo.event.title}${typeLabel}${eventInfo.event.extendedProps.time}`}
>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>
{eventInfo.event.title}
</span>
<span></span>
<span>{eventInfo.event.extendedProps.time}</span>
</div>
);
};
{/* CSS inline para centralizar */}
<style jsx>{`
.doctor-calendar-container {
display: flex;
flex-direction: column;
align-items: center; /* centraliza horizontal */
justify-content: center; /* centraliza vertical se precisar */
width: 100%;
padding: 20px;
}
return (
<div className="page-wrapper">
<div className="calendar-container" style={{ padding: 20, display: "flex", justifyContent: "center" }}>
<div style={{ width: "95%", maxWidth: "none" }}>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: "prev,next",
center: "title",
right: "today,dayGridMonth,timeGridWeek,timeGridDay"
}}
buttonText={{
today: "Hoje",
month: "Mês",
week: "Semana",
day: "Dia"
}}
locale={ptBrLocale}
height="80vh"
dateClick={handleDateClick}
.calendar-title {
margin-bottom: 20px;
text-align: center;
}
/* intervalos de meia hora */
slotDuration="00:30:00"
slotLabelInterval="00:30"
.calendar-wrapper {
max-width: 900px; /* largura máxima do calendário */
width: 100%;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
`}</style>
/* ADICIONADO PARA DEIXAR A PARTE AZUL ESTILIZÁVEL */
slotLabelContent={(arg) => {
return {
html: `<span class="fc-timegrid-slot-label-cushion" data-time="${arg.text}">${arg.text}</span>`
};
}}
events={events.map((ev) => ({
id: ev.id,
title: ev.title,
start: `${ev.date}T${ev.time}:00`,
color: ev.color,
extendedProps: {
type: ev.type,
time: ev.time,
color: ev.color,
appointmentData: ev.appointmentData
}
}))}
eventContent={renderEventContent}
eventClick={handleEventClick}
dayCellClassNames="calendar-day-cell"
/>
</div>
</div>
</div>
);
}
export default DoctorCalendar;

View File

@ -1,68 +1,526 @@
import { PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend } from "recharts";
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import "../../assets/css/index.css";
import { getFullName, getUserId } from "../../utils/userInfo";
import { getUserRole } from "../../utils/userInfo";
const AvatarForm = "/img/AvatarForm.jpg";
const banner = "/img/banner.png";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend as ChartLegend,
} from 'chart.js';
import { Bar as ChartJSBar } from 'react-chartjs-2';
import { withTheme } from "@emotion/react";
const consultsData = [
{ name: "Consultas", value: 45 },
{ name: "Exames", value: 20 },
{ name: "Laudos", value: 15 },
{ name: "Receitas", value: 25 },
];
const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042"];
// Registrar componentes do Chart.js
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
ChartTooltip,
ChartLegend
);
function DoctorDashboard() {
const [patients, setPatients] = useState([]);
const [appointments, setAppointments] = useState([]);
const [todayAppointments, setTodayAppointments] = useState([]);
const [recentConsults, setRecentConsults] = useState([]);
const [followUpPatients, setFollowUpPatients] = useState([]);
const [alerts, setAlerts] = useState([]);
const [draftReports, setDraftReports] = useState([]);
// Estados para os gráficos médicos
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [previewUrl, setPreviewUrl] = useState(AvatarForm);
const tokenUsuario = getAccessToken();
const userId = getUserId();
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const requestOptions = {
method: "GET",
headers: {
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
redirect: "follow",
};
useEffect(() => {
const loadDoctorData = async () => {
try {
setLoading(true);
// Buscar pacientes
const patientsResponse = await fetch(
`${supabaseUrl}/rest/v1/patients`,
requestOptions
);
const patientsData = await patientsResponse.json();
const patientsArr = Array.isArray(patientsData) ? patientsData : [];
setPatients(patientsArr);
// Buscar consultas do médico (filtrar pelo doctor_id se disponível)
const appointmentsResponse = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
requestOptions
);
const appointmentsData = await appointmentsResponse.json();
const appointmentsArr = Array.isArray(appointmentsData) ? appointmentsData : [];
setAppointments(appointmentsArr);
// Buscar laudos em draft
const reportsResponse = await fetch(
`${supabaseUrl}/rest/v1/reports?status=eq.draft`,
requestOptions
);
const reportsData = await reportsResponse.json();
const reportsArr = Array.isArray(reportsData) ? reportsData : [];
setDraftReports(reportsArr);
// Processar dados específicos do médico
processTodayAppointments(appointmentsArr, patientsArr);
processRecentConsults(appointmentsArr, patientsArr);
processFollowUpPatients(appointmentsArr, patientsArr);
processConsultasMensais(appointmentsArr);
processComparecimentoData(appointmentsArr);
processAlerts(appointmentsArr, reportsArr);
} catch (error) {
console.error('Erro ao carregar dados do médico:', error);
} finally {
setLoading(false);
}
};
// Inject custom CSS for DoctorDashboard
const styleId = 'doctor-dashboard-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
[data-dashboard="doctor"] .custom-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
[data-dashboard="doctor"] .status-green {
background-color: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
[data-dashboard="doctor"] .status-yellow {
background-color: #fff8e1;
color: #f57f17;
border: 1px solid #ffecb3;
}
[data-dashboard="doctor"] .status-red {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
`;
document.head.appendChild(style);
}
loadDoctorData();
}, []);
// useEffect para atualizar o relógio em tempo real
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000); // Atualiza a cada segundo
return () => clearInterval(timer); // Limpa o timer quando o componente é desmontado
}, []);
// useEffect para carregar avatar do usuário (mesma lógica da navbar)
useEffect(() => {
const loadAvatar = async () => {
if (!userId) return;
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
const requestOptions = {
headers: myHeaders,
method: 'GET',
redirect: 'follow'
};
try {
const response = await fetch(`${supabaseUrl}/storage/v1/object/avatars/${userId}/avatar.png`, requestOptions);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setPreviewUrl(imageUrl);
return; // Avatar encontrado
}
} catch (error) {
}
// Se chegou até aqui, não encontrou avatar - mantém o padrão
};
loadAvatar();
}, [userId]);
// Processar agenda do dia
const processTodayAppointments = (appointmentsData, patientsData) => {
const today = new Date().toISOString().split('T')[0];
const todayAppts = appointmentsData.filter(apt => {
if (!apt.scheduled_at) return false;
const aptDate = apt.scheduled_at.split('T')[0];
return aptDate === today;
});
const todayWithPatients = todayAppts.map(apt => {
const patient = patientsData.find(p => p.id === apt.patient_id);
return {
...apt,
patient_name: patient?.name || patient?.full_name || 'Paciente não encontrado',
time: apt.scheduled_at ? apt.scheduled_at.split('T')[1].substring(0, 5) : ''
};
}).sort((a, b) => a.time.localeCompare(b.time));
setTodayAppointments(todayWithPatients);
};
// Processar consultas recentes
const processRecentConsults = (appointmentsData, patientsData) => {
const recent = appointmentsData
.filter(apt => apt.scheduled_at && new Date(apt.scheduled_at) < new Date())
.sort((a, b) => new Date(b.scheduled_at) - new Date(a.scheduled_at))
.slice(0, 5)
.map(apt => {
const patient = patientsData.find(p => p.id === apt.patient_id);
return {
...apt,
patient_name: patient?.name || patient?.full_name || 'Paciente não encontrado',
date: apt.scheduled_at ? new Date(apt.scheduled_at).toLocaleDateString('pt-BR') : ''
};
});
setRecentConsults(recent);
};
// Processar pacientes em acompanhamento
const processFollowUpPatients = (appointmentsData, patientsData) => {
// Selecionar pacientes com consultas recorrentes ou em tratamento
const followUp = patientsData.slice(0, 10);
setFollowUpPatients(followUp);
};
// Processar dados de consultas mensais
const processConsultasMensais = (appointmentsData) => {
// Lógica para processar consultas mensais para gráficos
// Implementar conforme necessário
};
// Processar dados de comparecimento
const processComparecimentoData = (appointmentsData) => {
// Lógica para processar dados de comparecimento
// Implementar conforme necessário
};
// Processar alertas
const processAlerts = (appointmentsData, reportsData = []) => {
const alertsArray = [];
// Verificar laudos em draft
if (reportsData.length > 0) {
alertsArray.push({
message: `${reportsData.length} laudo${reportsData.length > 1 ? 's' : ''} pendente${reportsData.length > 1 ? 's' : ''} de confirmação`,
type: 'warning',
icon: 'fa-file-text-o',
action: 'Confirmar',
link: '/doctor/reports'
});
}
// Verificar consultas próximas (próximas 2 horas)
const now = new Date();
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
appointmentsData.forEach(apt => {
if (apt.scheduled_at) {
const aptDate = new Date(apt.scheduled_at);
if (aptDate > now && aptDate <= twoHoursLater) {
alertsArray.push({
message: `Consulta em ${Math.round((aptDate - now) / (1000 * 60))} minutos`,
type: 'warning',
icon: 'fa-clock-o',
action: 'Ver',
link: '/doctor/appointments'
});
}
}
});
// Adicionar outros alertas conforme necessário
if (alertsArray.length === 0) {
alertsArray.push({
message: 'Nenhum alerta no momento',
type: 'info',
icon: 'fa-info-circle',
action: 'OK'
});
}
setAlerts(alertsArray);
};
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<h2 className="mb-4">📊 Dashboard do Médico</h2>
<div className="page-wrapper" data-dashboard="doctor">
<div className="content">
{/* Header com informações do médico */}
<div className="page-header">
<div className="row">
{/* Gráfico de Pizza */}
<div className="col-md-6">
<h5>Distribuição de Atividades</h5>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={consultsData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{consultsData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
{/* Gráfico de Barras */}
<div className="col-md-6">
<h5>Consultas por Semana</h5>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={[
{ semana: "Semana 1", consultas: 12 },
{ semana: "Semana 2", consultas: 18 },
{ semana: "Semana 3", consultas: 9 },
{ semana: "Semana 4", consultas: 15 },
]}>
<XAxis dataKey="semana" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="consultas" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>
<div className="col-sm-12">
<div className="user-info-banner" style={{
background: `linear-gradient(135deg, #004a99, #0077cc), url(${banner})`,
backgroundSize: 'cover',
borderRadius: '15px',
padding: '30px',
color: 'white',
marginBottom: '20px'
}}>
<div className="row align-items-center">
<div className="col-md-8">
<h2 className="mb-2" style={{color: 'white'}}>👨 Olá, Dr. {getFullName()}!</h2>
<p className="mb-2" style={{ color: 'white' }}>Hoje é mais um dia para transformar vidas. Revise sua agenda, acompanhe seus pacientes e siga fazendo a diferença com o MediConnect. 💙!</p>
<small className="opacity-75">
🕒 {currentTime.toLocaleString('pt-BR')}
</small>
</div>
<div className="col-md-4 text-right">
<img
src={previewUrl}
alt="Avatar"
className="rounded-circle"
style={{ width: '80px', height: '80px', objectFit: 'cover', border: '3px solid white' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Cards de estatísticas */}
<div className="row">
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg1">
<i className="fa fa-calendar-check-o" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{todayAppointments.length}</h3>
<span className="widget-title1">Consultas Hoje</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg2">
<i className="fa fa-users" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{patients.length}</h3>
<span className="widget-title2">Total Pacientes</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg3">
<i className="fa fa-file-text-o" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{draftReports.length}</h3>
<span className="widget-title3">Laudos Pendentes</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg4">
<i className="fa fa-bell" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{alerts.length}</h3>
<span className="widget-title4">Notificações</span>
</div>
</div>
</div>
</div>
{/* Ações rápidas */}
<div className="row mb-4">
<div className="col-12">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-body">
<div className="row">
<div className="col-md-3 col-sm-6 mb-3">
<Link to={`/${role}/consultaform`} className="btn btn-outline-primary btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-calendar-plus-o mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Nova Consulta
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<Link to={`/${role}/patientlist`} className="btn btn-outline-success btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-user mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Meus Pacientes
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<Link to={`/${role}/laudolist`} className="btn btn-outline-info btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-stethoscope mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Laudos
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<Link to={`/${role}/excecao`} className="btn btn-outline-warning btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-exclamation-triangle mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Exceções
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Alertas Médicos */}
{alerts.length > 0 && (
<div className="row mb-4">
<div className="col-12">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">🔔 Notificações Importantes</h4>
</div>
<div className="card-body">
{alerts.map((alert, index) => (
<div key={index} className={`alert alert-${alert.type === 'danger' ? 'danger' : alert.type === 'warning' ? 'warning' : 'info'} d-flex align-items-center mb-2`}>
<i className={`fa ${alert.icon} me-2`}></i>
<span className="flex-grow-1">{alert.message}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Seções Específicas do Médico */}
{/* Primeira linha - Agenda do Dia e Consultas Recentes */}
<div className="row mt-4">
<div className="col-md-6 mb-4">
<div className="card shadow-sm rounded p-3">
<h4 className="fw-semibold mb-3">
<i className="fa fa-calendar text-primary"></i> Consultas de Hoje
</h4>
{loading ? (
<div className="text-center text-muted" style={{ height: '300px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando agenda...</p>
</div>
</div>
) : todayAppointments.length > 0 ? (
<div className="agenda-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{todayAppointments.map((apt, index) => (
<div key={index} className="d-flex align-items-center p-3 border-bottom">
<div className="time-badge bg-primary text-white px-2 py-1 rounded me-3" style={{ minWidth: '60px', textAlign: 'center' }}>
{apt.time}
</div>
<div className="flex-grow-1">
<h6 className="mb-1">{apt.patient_name}</h6>
<small className="text-muted">{apt.chief_complaint || 'Consulta de rotina'}</small>
</div>
<div>
<span className={`badge ${apt.status === 'completed' ? 'bg-success' : 'bg-warning'}`}>
{apt.status === 'completed' ? 'Realizada' : 'Agendada'}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted py-5">
<i className="fa fa-calendar-o fa-3x mb-3"></i>
<p>Nenhuma consulta agendada para hoje</p>
</div>
)}
</div>
</div>
<div className="col-md-6 mb-4">
<div className="card shadow-sm rounded p-3">
<h4 className="fw-semibold mb-3">
<i className="fa fa-history text-info"></i> Atendimentos Recentes
</h4>
{loading ? (
<div className="text-center text-muted" style={{ height: '300px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando consultas...</p>
</div>
</div>
) : recentConsults.length > 0 ? (
<div className="recent-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{recentConsults.map((consult, index) => (
<div key={index} className="d-flex align-items-center p-3 border-bottom">
<div className="patient-avatar bg-secondary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style={{ width: '40px', height: '40px' }}>
{consult.patient_name.charAt(0)}
</div>
<div className="flex-grow-1">
<h6 className="mb-1">{consult.patient_name}</h6>
<small className="text-muted">{consult.date}</small>
</div>
<button className="btn btn-sm btn-outline-primary">Ver Laudo</button>
</div>
))}
</div>
) : (
<div className="text-center text-muted py-5">
<i className="fa fa-file-text-o fa-3x mb-3"></i>
<p>Nenhuma consulta recente</p>
</div>
)}
</div>
</div>
</div>
{/* Charts Section */}
</div>
</div>
);
}
export default DoctorDashboard;
export default DoctorDashboard;

View File

@ -1,66 +0,0 @@
import React, { useState } from "react";
function PatientList() {
const [searchTerm, setSearchTerm] = useState("");
const patients = [
{
nome: "João Miguel",
cpf: "091.959.495-69",
telefone: "+55 (75) 99961-7296",
email: "Joaomiguel80@gmail.com",
},
];
const filteredPatients = patients.filter(
(p) =>
p.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.cpf.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.email.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="main-content">
{/* Barra de Pesquisa */}
<input
type="text"
placeholder="Pesquisar por nome, CPF ou e-mail"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-bar"
/>
{/* Tabela */}
<div className="table-container">
<h3 className="text-center mb-4">Lista de Pacientes</h3>
<table className="table table-bordered">
<thead>
<tr>
<th>Nome</th>
<th>CPF</th>
<th>Telefone</th>
<th>Email</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{filteredPatients.map((p, idx) => (
<tr key={idx}>
<td>{p.nome}</td>
<td>{p.cpf}</td>
<td>{p.telefone}</td>
<td>{p.email}</td>
<td>
<button className="btn btn-primary btn-sm mr-2">Laudo</button>
<button className="btn btn-success btn-sm">Receita</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default PatientList;

View File

@ -0,0 +1,356 @@
import React, { useEffect, useMemo, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
// Adicione uma função para pegar o papel do usuário
import { getUserRole } from '../../utils/userInfo.js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const API_ROOT = `${supabaseUrl}/rest/v1`;
const AUTH_URL = `${supabaseUrl}/auth/v1/user`; // Endpoint para pegar dados do user logado
const API_URL = `${API_ROOT}/doctor_exceptions`;
const API_DOCTORS = `${API_ROOT}/doctors`;
const API_KEY = supabaseAK;
export default function Doctorexceçao() {
const token = getAccessToken();
// Verifica o papel do usuário (admin ou médico)
const userRole = getUserRole();
const [exceptions, setExceptions] = useState([]);
const [currentDoctor, setCurrentDoctor] = useState(null); // Estado para o médico logado
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// ---------- CONFIGURAÇÕES COMUNS ----------
const commonHeaders = {
apikey: API_KEY,
Authorization: `Bearer ${token}`,
};
// ---------- 1. IDENTIFICAR USUÁRIO LOGADO ----------
const loadCurrentUser = async () => {
try {
setLoading(true);
if (userRole === 'admin') {
// Se for admin, carrega todas as exceções e define um médico padrão
await loadExceptions();
setCurrentDoctor({ full_name: 'Administrador' });
return;
}
// 1. Pega os dados da autenticação (Auth User)
const resAuth = await fetch(AUTH_URL, { headers: commonHeaders });
if (!resAuth.ok) throw new Error("Falha ao autenticar usuário");
const user = await resAuth.json();
// 2. Busca o perfil do médico correspondente na tabela 'doctors'
// Assumindo que o ID do médico na tabela é igual ao ID do Auth (UUID)
const resDoc = await fetch(`${API_DOCTORS}?user_id=eq.${user.id}`, { headers: commonHeaders });
if (!resDoc.ok) throw new Error("Perfil de médico não encontrado");
const docsData = await resDoc.json();
if (docsData.length > 0) {
const doc = docsData[0];
setCurrentDoctor(doc);
// Só carrega as exceções depois de saber quem é o médico
loadExceptions(doc.id);
} else {
setErr("Seu usuário não está cadastrado como médico.");
}
} catch (e) {
setErr(e.message || "Erro ao carregar perfil do usuário");
} finally {
setLoading(false);
}
};
// ---------- 2. CARREGAR DADOS FILTRADOS ----------
// Agora recebe o doctorId como argumento para filtrar na API
const loadExceptions = async (doctorId) => {
try {
let url;
if (userRole === 'admin') {
url = `${API_URL}?select=*,doctor:doctor_id(id,full_name)`; // join para trazer nome do médico
} else {
url = `${API_URL}?select=*&doctor_id=eq.${doctorId}`;
}
const res = await fetch(url, { headers: commonHeaders });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setExceptions(Array.isArray(data) ? data : []);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
if (token) {
loadCurrentUser();
}
}, [token]);
// ---------- CRIAR EXCEÇÃO ----------
const createException = async (payload) => {
try {
const body = {
...payload,
// Garante que quem cria é o próprio médico
created_by: currentDoctor.id,
doctor_id: currentDoctor.id
};
const res = await fetch(API_URL, {
method: "POST",
headers: {
...commonHeaders,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
await res.json();
// Recarrega usando o ID do médico logado
await loadExceptions(currentDoctor.id);
Swal.fire("Sucesso!", "Exceção criada com sucesso.", "success");
} catch (e) {
Swal.fire("Erro ao criar", e.message || "Falha ao criar exceção", "error");
}
};
// ---------- DELETAR EXCEÇÃO ----------
const deleteException = async (id) => {
const confirm = await Swal.fire({
title: "Excluir exceção?",
text: "Essa ação não pode ser desfeita.",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Sim, excluir",
cancelButtonText: "Cancelar",
});
if (!confirm.isConfirmed) return;
try {
const res = await fetch(`${API_URL}?id=eq.${id}`, {
method: "DELETE",
headers: commonHeaders,
});
if (!res.ok) throw new Error(await res.text());
// Recarrega usando o ID do médico logado
await loadExceptions(currentDoctor.id);
Swal.fire("Removida!", "Exceção excluída com sucesso.", "success");
} catch (e) {
Swal.fire("Erro ao excluir", e.message || "Falha ao excluir", "error");
}
};
// ---------- EVENTOS DO CALENDÁRIO ----------
const events = useMemo(() => {
return exceptions.map((ex) => {
const isBlock = ex.kind === "bloqueio";
return {
id: ex.id,
title: isBlock ? "Bloqueio" : "Liberação",
start: ex.date,
allDay: true,
backgroundColor: isBlock ? "#ef4444" : "#22c55e",
borderColor: isBlock ? "#b91c1c" : "#15803d",
textColor: "#fff",
};
});
}, [exceptions]);
// ---------- HANDLERS ----------
const handleDateClick = async (info) => {
if (!currentDoctor) {
Swal.fire("Erro", "Perfil de médico não identificado.", "error");
return;
}
// REMOVIDA A ETAPA 1 (Seleção de médico)
// Agora vai direto para a seleção do tipo de exceção
// 1 Tipo da exceção
const s2 = await Swal.fire({
title: `Nova exceção — ${info.dateStr}`,
text: "O que deseja fazer nesta data?",
input: "select",
inputOptions: {
bloqueio: "Bloqueio (Não atender)",
liberacao: "Liberação (Atender extra)",
},
inputPlaceholder: "Selecione o tipo",
showCancelButton: true,
confirmButtonText: "Continuar",
didOpen: (popup) => {
popup.style.position = "fixed";
popup.style.top = "230px";
}
});
if (!s2.isConfirmed || !s2.value) return;
const kind = s2.value;
// 2 Motivo
const form = await Swal.fire({
title: "Motivo (opcional)",
input: "text",
inputPlaceholder: "Ex: Congresso, folga, manutenção...",
showCancelButton: true,
confirmButtonText: "Criar exceção",
didOpen: (popup) => {
popup.style.position = "fixed";
popup.style.top = "230px";
}
});
if (!form.isConfirmed) return;
const payload = {
// O ID vem do estado global do componente
doctor_id: currentDoctor.id,
date: info.dateStr,
kind,
reason: form.value || null,
};
await createException(payload);
};
const handleEventClick = async (info) => {
const e = exceptions.find((x) => x.id === info.event.id);
if (!e) return;
await Swal.fire({
title: e.kind === "bloqueio" ? "Bloqueio" : "Liberação",
html: `<b>Data:</b> ${e.date}<br>
<b>Motivo:</b> ${e.reason || "-"}`,
icon: "info",
showCancelButton: true,
confirmButtonText: "Excluir",
cancelButtonText: "Fechar",
}).then((r) => {
if (r.isConfirmed) deleteException(e.id);
});
};
// ---------- UI ----------
return (
<div className="page-wrapper">
<div className="content">
<div className="page-header">
<div className="row align-items-center">
<div className="col">
<h4 className="page-title">
Minhas Exceções {currentDoctor ? `(${currentDoctor.full_name})` : ""}
</h4>
<span className="text-muted">
Clique numa data para bloquear ou liberar sua agenda
</span>
</div>
</div>
</div>
{/* Calendário */}
<div className="row">
<div className="col-12">
<div
className="card"
style={{
borderRadius: 10,
padding: 16,
border: "1px solid rgba(0,0,0,0.08)",
}}
>
{loading ? (
<p className="text-muted m-0">Identificando médico e carregando agenda...</p>
) : err ? (
<div className="alert alert-danger">{err}</div>
) : (
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={ptBrLocale}
height="auto"
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,dayGridWeek,dayGridDay",
}}
events={events}
dateClick={handleDateClick}
eventClick={handleEventClick}
/>
)}
</div>
</div>
</div>
{/* Lista de exceções */}
<div className="row" style={{ marginTop: 16 }}>
<div className="col-12">
<div className="card" style={{ borderRadius: 10 }}>
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="card-title m-0">Minhas Exceções Registradas</h5>
<small className="text-muted">{exceptions.length} registro(s)</small>
</div>
<div className="card-body" style={{ paddingTop: 8 }}>
{loading ? (
<p className="text-muted m-0">Carregando...</p>
) : !err && exceptions.length === 0 ? (
<p className="text-muted m-0">Nenhuma exceção encontrada para você.</p>
) : (
<div className="table-responsive">
<table className="table table-border table-striped custom-table mb-0">
<thead>
<tr>
<th>Data</th>
<th>Tipo</th>
<th>Motivo</th>
{userRole === 'admin' && <th>Médico</th>}
<th>Ações</th>
</tr>
</thead>
<tbody>
{exceptions.map((ex) => (
<tr key={ex.id}>
<td>{ex.date}</td>
<td>
{ex.kind === "bloqueio" ? (
<span className="custom-badge status-red">Bloqueio</span>
) : (
<span className="custom-badge status-green">Liberação</span>
)}
</td>
<td>{ex.reason || "-"}</td>
{userRole === 'admin' && <td>{ex.doctor?.full_name || '-'}</td>}
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => deleteException(ex.id)}
>
Excluir
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,790 @@
// src/pages/DoctorApp/DoctorProntuario.jsx
import React, { useState, useEffect } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
function DoctorProntuario() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
// Receber paciente via state da navegação ou buscar por ID
const [paciente, setPaciente] = useState(location.state?.paciente || null);
const [prontuario, setProntuario] = useState("");
const [historico, setHistorico] = useState([]);
const [retorno, setRetorno] = useState("3 meses");
const [peso, setPeso] = useState("");
const [altura, setAltura] = useState("");
const [imc, setImc] = useState("");
const [pressaoArterial, setPressaoArterial] = useState("");
const [temperatura, setTemperatura] = useState("");
const [observacoes, setObservacoes] = useState("");
// Estados para anexos
const [anexos, setAnexos] = useState([]);
const [arquivoSelecionado, setArquivoSelecionado] = useState(null);
// Estados para o cronômetro
const [atendimentoIniciado, setAtendimentoIniciado] = useState(false);
const [atendimentoPausado, setAtendimentoPausado] = useState(false);
const [atendimentoFinalizado, setAtendimentoFinalizado] = useState(false);
const [tempoDecorrido, setTempoDecorrido] = useState(0);
const [cronometroAtivo, setCronometroAtivo] = useState(false);
// Dados mockados dos pacientes (igual ao da lista)
const pacientesMock = [
{
id: 1,
nome: "João Silva Santos",
cpf: "123.456.789-00",
data_nascimento: "15/03/1985",
telefone: "(11) 99999-9999",
email: "joao.silva@email.com",
status: "ativo",
endereco: "Rua das Flores, 123 - São Paulo/SP",
idade: "39 anos",
primeiraConsulta: "15/01/2024",
convenio: "Unimed",
atendimentos: 3,
faltas: 0
},
{
id: 2,
nome: "Maria Oliveira Costa",
cpf: "987.654.321-00",
data_nascimento: "22/07/1990",
telefone: "(11) 88888-8888",
email: "maria.oliveira@email.com",
status: "ativo",
endereco: "Av. Paulista, 1000 - São Paulo/SP",
idade: "33 anos",
primeiraConsulta: "20/02/2024",
convenio: "Amil",
atendimentos: 2,
faltas: 1
},
{
id: 3,
nome: "Pedro Almeida Souza",
cpf: "456.789.123-00",
data_nascimento: "10/12/1978",
telefone: "(11) 77777-7777",
email: "pedro.almeida@email.com",
status: "inativo",
endereco: "Rua Augusta, 500 - São Paulo/SP",
idade: "45 anos",
primeiraConsulta: "05/03/2024",
convenio: "Bradesco Saúde",
atendimentos: 1,
faltas: 0
},
{
id: 4,
nome: "Ana Pereira Lima",
cpf: "789.123.456-00",
data_nascimento: "05/09/1995",
telefone: "(11) 66666-6666",
email: "ana.pereira@email.com",
status: "ativo",
endereco: "Rua Consolação, 200 - São Paulo/SP",
idade: "28 anos",
primeiraConsulta: "10/04/2024",
convenio: "SulAmérica",
atendimentos: 4,
faltas: 0
},
{
id: 5,
nome: "Carlos Rodrigues Ferreira",
cpf: "321.654.987-00",
data_nascimento: "30/01/1982",
telefone: "(11) 55555-5555",
email: "carlos.rodrigues@email.com",
status: "arquivado",
endereco: "Alameda Santos, 800 - São Paulo/SP",
idade: "42 anos",
primeiraConsulta: "25/05/2024",
convenio: "NotreDame Intermédica",
atendimentos: 2,
faltas: 1
}
];
// Histórico médico mockado específico para cada paciente
const historicoPorPaciente = {
1: [
{
id: 1,
data: "15/01/2024",
tipo: "Consulta Inicial",
diagnostico: "Paciente em bom estado geral. Realizado check-up preventivo.",
medico: "Dr. José Rodrigues",
duracao: "45 minutos",
retorno: "6 meses",
dadosAntropometricos: {
peso: "78.5 kg",
altura: "175 cm",
imc: "25.6",
pressaoArterial: "120/80 mmHg",
temperatura: "36.5°C"
},
observacoes: "Paciente sem queixas. Exames dentro da normalidade.",
anexos: [
{ id: 1, nome: "raio-x-torax.pdf", tipo: "pdf", tamanho: "2.4 MB" }
]
}
],
2: [
{
id: 1,
data: "20/02/2024",
tipo: "Consulta de Rotina",
diagnostico: "Controle de pressão arterial.",
medico: "Dr. José Rodrigues",
duracao: "30 minutos",
retorno: "3 meses",
dadosAntropometricos: {
peso: "65.2 kg",
altura: "165 cm",
imc: "23.9",
pressaoArterial: "118/78 mmHg",
temperatura: "36.7°C"
},
observacoes: "Paciente com pressão controlada.",
anexos: []
}
],
// Adicione históricos para os outros IDs...
};
useEffect(() => {
// Se o paciente foi passado via state, usa ele
if (location.state?.paciente) {
setPaciente(location.state.paciente);
const historicoPaciente = historicoPorPaciente[location.state.paciente.id] || [];
setHistorico(historicoPaciente);
}
// Se não, busca pelo ID na URL
else if (id) {
const pacienteEncontrado = pacientesMock.find(p => p.id === parseInt(id));
setPaciente(pacienteEncontrado);
const historicoPaciente = historicoPorPaciente[parseInt(id)] || [];
setHistorico(historicoPaciente);
}
}, [id, location.state]);
// Resto do código do cronômetro permanece igual...
useEffect(() => {
let intervalo = null;
if (cronometroAtivo && !atendimentoPausado && !atendimentoFinalizado) {
intervalo = setInterval(() => {
setTempoDecorrido(tempo => tempo + 1);
}, 1000);
} else {
clearInterval(intervalo);
}
return () => clearInterval(intervalo);
}, [cronometroAtivo, atendimentoPausado, atendimentoFinalizado]);
const formatarTempo = (segundos) => {
const minutos = Math.floor(segundos / 60);
const segs = segundos % 60;
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
};
const formatarTempoExtenso = (segundos) => {
const horas = Math.floor(segundos / 3600);
const minutos = Math.floor((segundos % 3600) / 60);
const segs = segundos % 60;
if (horas > 0) {
return `${horas}h ${minutos}m ${segs}s`;
}
return `${minutos}m ${segs}s`;
};
const iniciarAtendimento = () => {
setAtendimentoIniciado(true);
setAtendimentoPausado(false);
setAtendimentoFinalizado(false);
setCronometroAtivo(true);
setTempoDecorrido(0);
};
const pausarAtendimento = () => {
setAtendimentoPausado(true);
setCronometroAtivo(false);
};
const retomarAtendimento = () => {
setAtendimentoPausado(false);
setCronometroAtivo(true);
};
const finalizarAtendimento = () => {
const confirmacao = window.confirm(
`Tem certeza que deseja finalizar o atendimento?\n\n` +
`Paciente: ${paciente.nome}\n` +
`Duração: ${formatarTempoExtenso(tempoDecorrido)}\n\n` +
`Após finalizar, o tempo será salvo e não poderá ser alterado.`
);
if (confirmacao) {
setAtendimentoFinalizado(true);
setCronometroAtivo(false);
setAtendimentoIniciado(false);
setAtendimentoPausado(false);
}
};
const calcularIMC = () => {
if (peso && altura) {
const alturaMetros = parseInt(altura) / 100;
const imcCalculado = (parseFloat(peso) / (alturaMetros * alturaMetros)).toFixed(1);
setImc(imcCalculado);
}
};
useEffect(() => {
calcularIMC();
}, [peso, altura]);
const handleArquivoSelecionado = (event) => {
const file = event.target.files[0];
if (file) {
setArquivoSelecionado(file);
}
};
const adicionarAnexo = () => {
if (arquivoSelecionado) {
const novoAnexo = {
id: anexos.length + 1,
nome: arquivoSelecionado.name,
tipo: arquivoSelecionado.type,
tamanho: (arquivoSelecionado.size / 1024 / 1024).toFixed(1) + " MB",
arquivo: arquivoSelecionado,
data: new Date().toLocaleString('pt-BR')
};
setAnexos([...anexos, novoAnexo]);
setArquivoSelecionado(null);
document.getElementById('fileInput').value = '';
}
};
const removerAnexo = (id) => {
setAnexos(anexos.filter(anexo => anexo.id !== id));
};
const handleSalvarProntuario = () => {
if (prontuario.trim()) {
const duracaoFormatada = atendimentoFinalizado ? formatarTempoExtenso(tempoDecorrido) : "Não registrado";
const dadosAntropometricosText = `
DADOS ANTROPOMÉTRICOS:
- Peso: ${peso || "Não informado"}
- Altura: ${altura || "Não informado"}
- IMC: ${imc || "Não calculado"}
- Pressão Arterial: ${pressaoArterial || "Não informada"}
- Temperatura: ${temperatura || "Não informada"}
`;
const observacoesText = observacoes ? `\nOBSERVAÇÕES:\n${observacoes}` : "";
const textoCompleto = `${dadosAntropometricosText}${observacoesText}\n\nDIAGNÓSTICO E CONDUTA:\n${prontuario}`;
const novoRegistro = {
id: historico.length + 1,
data: new Date().toLocaleDateString('pt-BR'),
tipo: "Consulta de Rotina",
diagnostico: textoCompleto,
medico: "Dr. Médico Atual",
duracao: duracaoFormatada,
retorno: retorno,
dadosAntropometricos: {
peso: peso || "Não informado",
altura: altura || "Não informado",
imc: imc || "Não calculado",
pressaoArterial: pressaoArterial || "Não informada",
temperatura: temperatura || "Não informada"
},
observacoes: observacoes,
anexos: [...anexos]
};
setHistorico([novoRegistro, ...historico]);
// Limpar formulário
setProntuario("");
setPeso("");
setAltura("");
setImc("");
setPressaoArterial("");
setTemperatura("");
setObservacoes("");
setAnexos([]);
setAtendimentoIniciado(false);
setAtendimentoPausado(false);
setAtendimentoFinalizado(false);
setCronometroAtivo(false);
setTempoDecorrido(0);
alert(`Prontuário salvo com sucesso! Duração da consulta: ${duracaoFormatada}`);
}
};
const visualizarCadastro = () => {
if (paciente) {
alert(`Cadastro Completo de ${paciente.nome}\n\nCPF: ${paciente.cpf}\nTelefone: ${paciente.telefone}\nEmail: ${paciente.email}\nEndereço: ${paciente.endereco}\nData Nasc.: ${paciente.data_nascimento}\nConvênio: ${paciente.convenio || "Não informado"}\nStatus: ${paciente.status}`);
}
};
const getIconePorTipo = (tipo) => {
if (tipo.includes('image')) return '🖼️';
if (tipo.includes('pdf')) return '📄';
if (tipo.includes('word')) return '📝';
return '📎';
};
if (!paciente) {
return (
<div className="main-content">
<h2>Paciente não encontrado</h2>
<button
onClick={() => navigate("/doctor/prontuariolist")}
className="btn btn-secondary"
>
Voltar para lista de pacientes
</button>
</div>
);
}
return (
<div className="main-content">
{/* Header com informações do paciente específico */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="d-flex align-items-center">
<button
onClick={() => navigate("/doctor/prontuariolist")}
className="btn btn-primary me-3"
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontWeight: 'bold'
}}
>
&lt;
</button>
<div>
<h2 className="text-primary mb-0">Prontuário Médico</h2>
<small className="text-muted">Paciente ID: {paciente.id} {paciente.nome}</small>
</div>
</div>
<div>
<button
onClick={visualizarCadastro}
className="btn btn-outline-primary me-2"
>
Visualizar Cadastro
</button>
{/* Controle do atendimento */}
{!atendimentoIniciado && !atendimentoFinalizado ? (
<button
onClick={iniciarAtendimento}
className="btn btn-success"
>
Iniciar Atendimento
</button>
) : atendimentoPausado ? (
<button
onClick={retomarAtendimento}
className="btn btn-warning me-2"
>
Retomar
</button>
) : atendimentoIniciado ? (
<button
onClick={pausarAtendimento}
className="btn btn-secondary me-2"
>
Pausar
</button>
) : null}
{(atendimentoIniciado || atendimentoPausado) && (
<button
onClick={finalizarAtendimento}
className="btn btn-danger"
>
Finalizar
</button>
)}
</div>
</div>
{/* Cronômetro */}
{(atendimentoIniciado || atendimentoPausado || atendimentoFinalizado) && (
<div className={`alert d-flex justify-content-between align-items-center mb-4 ${
atendimentoFinalizado ? 'alert-success' :
atendimentoPausado ? 'alert-warning' : 'alert-info'
}`}>
<div>
<strong>
{atendimentoFinalizado ? '✅ Atendimento Finalizado' :
atendimentoPausado ? '⏸️ Atendimento Pausado' : '▶️ Atendimento em Andamento'}
</strong>
{atendimentoPausado && (
<small className="ms-2 text-muted">(Tempo pausado)</small>
)}
</div>
<div className="cronometro">
<span className={`badge fs-6 ${
atendimentoFinalizado ? 'bg-success' :
atendimentoPausado ? 'bg-warning' : 'bg-primary'
}`}>
{formatarTempo(tempoDecorrido)}
{atendimentoFinalizado && ` (${formatarTempoExtenso(tempoDecorrido)})`}
</span>
</div>
</div>
)}
{/* Resumo do Paciente ESPECÍFICO */}
<div className="card mb-4">
<div className="card-header bg-light d-flex justify-content-between align-items-center">
<h5 className="mb-0">Resumo do Paciente</h5>
<span className={`badge ${
paciente.status === 'ativo' ? 'bg-success' :
paciente.status === 'inativo' ? 'bg-secondary' : 'bg-warning'
}`}>
{paciente.status}
</span>
</div>
<div className="card-body">
<div className="text-center mb-4">
<h4 className="text-primary mb-2">{paciente.nome}</h4>
<div className="d-flex justify-content-center gap-4">
<span className="text-muted">ID: {paciente.id}</span>
<span className="text-muted">CPF: {paciente.cpf}</span>
</div>
</div>
<div className="row">
<div className="col-md-3 text-center border-end">
<div className="mb-3">
<div className="text-muted small">IDADE</div>
<div className="h5 text-primary">{paciente.idade || "N/A"}</div>
<div className="text-muted small">Nasc: {paciente.data_nascimento}</div>
</div>
</div>
<div className="col-md-3 text-center border-end">
<div className="mb-3">
<div className="text-muted small">PRIMEIRA CONSULTA</div>
<div className="h5 text-primary">{paciente.primeiraConsulta || "N/A"}</div>
<div className="text-muted small">Convênio: {paciente.convenio || "N/A"}</div>
</div>
</div>
<div className="col-md-3 text-center border-end">
<div className="mb-3">
<div className="text-muted small">ATENDIMENTOS</div>
<div className="h5 text-success">{paciente.atendimentos || 0}</div>
<div className="text-muted small">realizados</div>
</div>
</div>
<div className="col-md-3 text-center">
<div className="mb-3">
<div className="text-muted small">FALTAS</div>
<div className="h5 text-danger">{paciente.faltas || 0}</div>
<div className="text-muted small">registradas</div>
</div>
</div>
</div>
</div>
</div>
{/* Resto do código do prontuário permanece igual... */}
<div className="row">
{/* Coluna 1: Dados da Consulta */}
<div className="col-lg-4 col-md-6">
<div className="card mb-4">
<div className="card-header bg-light">
<h6 className="mb-0"> Duração da Consulta</h6>
</div>
<div className="card-body text-center">
{atendimentoFinalizado ? (
<div>
<div className="display-6 text-success mb-2">
{formatarTempo(tempoDecorrido)}
</div>
<small className="text-muted">Tempo final: {formatarTempoExtenso(tempoDecorrido)}</small>
</div>
) : atendimentoIniciado ? (
<div>
<div className={`display-6 mb-2 ${
atendimentoPausado ? 'text-warning' : 'text-primary'
}`}>
{formatarTempo(tempoDecorrido)}
</div>
<small className="text-muted">
{atendimentoPausado ? 'Tempo pausado' : 'Tempo decorrido'}
</small>
</div>
) : (
<div>
<div className="display-6 text-muted mb-2">
00:00
</div>
<small className="text-muted">Inicie o atendimento</small>
</div>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-header bg-light">
<h6 className="mb-0">📊 Dados Antropométricos</h6>
</div>
<div className="card-body">
<div className="row g-2">
<div className="col-6">
<label className="form-label small">Peso (kg)</label>
<input
type="text"
value={peso}
onChange={(e) => setPeso(e.target.value)}
className="form-control form-control-sm"
placeholder="70.5"
/>
</div>
<div className="col-6">
<label className="form-label small">Altura (cm)</label>
<input
type="text"
value={altura}
onChange={(e) => setAltura(e.target.value)}
className="form-control form-control-sm"
placeholder="175"
/>
</div>
<div className="col-12">
<label className="form-label small">IMC</label>
<input
type="text"
value={imc}
readOnly
className="form-control form-control-sm bg-light"
placeholder="Calculado automaticamente"
/>
</div>
<div className="col-6">
<label className="form-label small">Pressão Arterial</label>
<input
type="text"
value={pressaoArterial}
onChange={(e) => setPressaoArterial(e.target.value)}
className="form-control form-control-sm"
placeholder="120/80 mmHg"
/>
</div>
<div className="col-6">
<label className="form-label small">Temperatura</label>
<input
type="text"
value={temperatura}
onChange={(e) => setTemperatura(e.target.value)}
className="form-control form-control-sm"
placeholder="36.5°C"
/>
</div>
<div className="col-12">
<label className="form-label small">Observações</label>
<textarea
value={observacoes}
onChange={(e) => setObservacoes(e.target.value)}
className="form-control form-control-sm"
placeholder="Observações adicionais..."
rows="2"
/>
</div>
</div>
</div>
</div>
</div>
{/* Coluna 2: Prontuário Principal */}
<div className="col-lg-4 col-md-6">
<div className="card mb-4 h-100">
<div className="card-header bg-light">
<h6 className="mb-0">📝 Prontuário</h6>
</div>
<div className="card-body d-flex flex-column">
<div className="mb-3 p-3 bg-light rounded">
<small className="text-muted">
<strong>Dados incluídos automaticamente:</strong><br />
Peso: {peso || "Não informado"}<br />
Altura: {altura || "Não informado"}<br />
IMC: {imc || "Não calculado"}<br />
Pressão: {pressaoArterial || "Não informada"}<br />
Temperatura: {temperatura || "Não informada"}<br />
{observacoes && `• Observações: ${observacoes}`}
</small>
</div>
<textarea
value={prontuario}
onChange={(e) => setProntuario(e.target.value)}
placeholder="Digite o diagnóstico, conduta e prescrição médica..."
rows="8"
className="form-control flex-grow-1"
style={{ minHeight: '200px' }}
/>
<div className="mt-3">
<button
onClick={handleSalvarProntuario}
className="btn btn-primary w-100"
disabled={!prontuario.trim()}
>
💾 Salvar Prontuário
</button>
</div>
</div>
</div>
</div>
{/* Coluna 3: Anexos e Retorno */}
<div className="col-lg-4 col-md-12">
<div className="card mb-4">
<div className="card-header bg-light">
<h6 className="mb-0">📎 Anexos</h6>
</div>
<div className="card-body">
<div className="mb-3">
<input
type="file"
id="fileInput"
onChange={handleArquivoSelecionado}
className="form-control form-control-sm"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.txt"
/>
<small className="text-muted">PDF, imagens, documentos (máx. 10MB)</small>
</div>
{arquivoSelecionado && (
<div className="alert alert-info py-2 mb-3">
<small>
<strong>Arquivo selecionado:</strong> {arquivoSelecionado.name}
({(arquivoSelecionado.size / 1024 / 1024).toFixed(1)} MB)
</small>
<button
onClick={adicionarAnexo}
className="btn btn-sm btn-success ms-2"
>
Adicionar
</button>
</div>
)}
{anexos.length > 0 && (
<div className="anexos-list" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{anexos.map(anexo => (
<div key={anexo.id} className="d-flex justify-content-between align-items-center border-bottom py-2">
<div className="flex-grow-1">
<div className="d-flex align-items-center">
<span className="me-2">{getIconePorTipo(anexo.tipo)}</span>
<small className="text-truncate">{anexo.nome}</small>
</div>
<small className="text-muted">{anexo.tamanho} {anexo.data}</small>
</div>
<button
onClick={() => removerAnexo(anexo.id)}
className="btn btn-sm btn-outline-danger"
title="Remover anexo"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-header bg-light">
<h6 className="mb-0">📅 Retorno</h6>
</div>
<div className="card-body">
<select
value={retorno}
onChange={(e) => setRetorno(e.target.value)}
className="form-select"
>
<option>1 mês</option>
<option>3 meses</option>
<option>6 meses</option>
<option>1 ano</option>
<option>Sem retorno</option>
</select>
</div>
</div>
<div className="card">
<div className="card-header bg-light">
<div className="d-flex justify-content-between align-items-center">
<h6 className="mb-0">📋 Histórico Recente</h6>
<span className="badge bg-primary">{historico.length}</span>
</div>
</div>
<div className="card-body p-0">
{historico.length === 0 ? (
<div className="p-3 text-center text-muted">
Nenhum registro
</div>
) : (
<div className="historico-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{historico.slice(0, 5).map((registro) => (
<div key={registro.id} className="border-bottom p-3">
<div className="d-flex justify-content-between align-items-start mb-2">
<small className="text-primary">{registro.data}</small>
<span className="badge bg-light text-dark small">{registro.duracao}</span>
</div>
<p className="small mb-2 text-truncate">{registro.diagnostico.split('\n')[0]}</p>
<div className="d-flex justify-content-between align-items-center">
<small className="text-muted">{registro.tipo}</small>
<small className="text-muted">Ret: {registro.retorno}</small>
</div>
{registro.anexos && registro.anexos.length > 0 && (
<div className="mt-1">
<small className="text-muted">
📎 {registro.anexos.length} anexo(s)
</small>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
export default DoctorProntuario;

View File

@ -0,0 +1,309 @@
// src/pages/DoctorApp/Patient/DoctorPatientList.jsx
import { Link } from "react-router-dom";
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import DoctorProntuario from "./DoctorProntuario";
import { getAccessToken } from "../../../utils/auth.js";
// Componente DropdownPortal (mantido igual)
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
useLayoutEffect(() => {
if (!isOpen) return;
if (!anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
if (left < 0) left = scrollX + 4;
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
const menu = menuRef.current;
if (menu && !menu.contains(e.target) && anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className}
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function DoctorPatientList() {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const [search, setSearch] = useState("");
const [patients, setPatients] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({});
const tokenUsuario = getAccessToken()
var myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/patients`, requestOptions)
.then(response => response.json())
.then(result => setPatients(Array.isArray(result) ? result : []))
.catch(error => console.log('error', error));
}, [])
const handleDelete = async (id) => {
const confirmDel = window.confirm("Tem certeza que deseja excluir este paciente?");
if (!confirmDel) return;
// Remove localmente (sem API)
setPatients((prev) => prev.filter((p) => p.id !== id));
setOpenDropdown(null);
};
const filteredPatients = patients.filter(p => {
if (!p) return false;
const nome = (p.full_name || "").toLowerCase();
const cpf = (p.cpf || "").toLowerCase();
const email = (p.email || "").toLowerCase();
const q = search.toLowerCase();
return nome.includes(q) || cpf.includes(q) || email.includes(q);
});
const [itemsPerPage1] = useState(10);
const [currentPage1, setCurrentPage1] = useState(1);
const indexOfLastPatient = currentPage1 * itemsPerPage1;
const indexOfFirstPatient = indexOfLastPatient - itemsPerPage1;
const currentPatients = filteredPatients.slice(indexOfFirstPatient, indexOfLastPatient);
const totalPages1 = Math.ceil(filteredPatients.length / itemsPerPage1);
useEffect(() => {
setCurrentPage1(1);
}, [search]);
const mascararCPF = (cpf = "") => {
if (cpf.length < 5) return cpf;
const inicio = cpf.slice(0, 3);
const fim = cpf.slice(-2);
return `${inicio}.***.***-${fim}`;
};
return (
<div className="content">
<div className="row ">
<div className="col-sm-4 col-3">
<h4 className="page-title">Prontuários</h4>
<input
type="text"
className="form-control"
placeholder="🔍 Buscar pacientes"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<br />
</div>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
<th>Nome</th>
<th>Cpf</th>
<th>Data de Nascimento</th>
<th>Telefone</th>
<th>Email</th>
<th>Status</th>
<th className="text-right">Ações</th>
</tr>
</thead>
<tbody>
{currentPatients.length > 0 ? (
currentPatients.map((p) => (
<tr key={p.id}>
<td>{p.full_name}</td>
<td>{mascararCPF(p.cpf)}</td>
<td>{p.birth_date}</td>
<td>{p.phone_mobile}</td>
<td>{p.email}</td>
<td>
<span className={`badge ${p.status === 'ativo' ? 'bg-success' :
p.status === 'inativo' ? 'bg-secondary' : 'bg-warning'
}`}>
{p.status}
</span>
</td>
<td className="text-right">
<div className="dropdown dropdown-action" style={{ display: "inline-flex", alignItems: "center", gap: "10px" }}>
{/* BOTÃO DE PRONTUÁRIO - FUNCIONANDO COM DADOS MOCKADOS */}
<Link
to="/doctor/doctorprontuario/${p.id}"
state={{ paciente: p }}
className="btn btn-sm btn-info"
title="Abrir Prontuário"
style={{
padding: "5px 10px",
fontSize: "12px",
display: "inline-flex",
alignItems: "center",
gap: "5px"
}}
>
<i className="fa fa-file-medical"></i> Prontuário
</Link>
{/* Menu de três pontinhos */}
<button
type="button"
ref={(el) => (anchorRefs.current[p.id] = el)}
className="action-icon"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === p.id ? null : p.id);
}}
>
<i className="fa fa-ellipsis-v"></i>
</button>
<DropdownPortal
anchorEl={anchorRefs.current[p.id]}
isOpen={openDropdown === p.id}
onClose={() => setOpenDropdown(null)}
className="dropdown-menu dropdown-menu-right show"
>
<Link
className="dropdown-item-custom"
to={`/edit-patient/${p.id}`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
>
<i className="fa fa-pencil m-r-5"></i> Editar
</Link>
<button
className="dropdown-item-custom dropdown-item-delete"
onClick={() => handleDelete(p.id)}
>
<i className="fa fa-trash-o m-r-5"></i> Excluir
</button>
</DropdownPortal>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="text-center text-muted">
Nenhum paciente encontrado
</td>
</tr>
)}
</tbody>
</table>
</div>
<nav className="mt-3">
<ul className="pagination justify-content-center">
{/* Ir para a primeira página */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(1)}>
{"<<"} {/* ou "Início" */}
</button>
</li>
{/* Botão de página anterior */}
<li className={`page-item ${currentPage1 === 1 ? "disabled" : ""}`}>
<button
className="page-link"
onClick={() => currentPage1 > 1 && setCurrentPage1(currentPage1 - 1)}
>
&lt;
</button>
</li>
{/* Números de página */}
<li className="page-item active">
<span className="page-link">{currentPage1}</span>
</li>
{/* Botão de próxima página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button
className="page-link"
onClick={() =>
currentPage1 < totalPages1 && setCurrentPage1(currentPage1 + 1)
}
>
&gt;
</button>
</li>
{/* Ir para a última página */}
<li className={`page-item ${currentPage1 === totalPages1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => setCurrentPage1(totalPages1)}>
{">>"} {/* ou "Fim" */}
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
);
}
export default DoctorPatientList;

View File

@ -0,0 +1,199 @@
import React from "react";
import "../../assets/css/hospital.css";
import { useEffect } from "react";
export default function HospitalLanding() {
const especialidades = [
{ nome: "Cardiologia", img: "/img/specialities-04.png" },
{ nome: "Neurologia", img: "/img/specialities-02.png" },
{ nome: "Ortopedia", img: "/img/specialities-03.png" },
{ nome: "Odontologia", img: "/img/specialities-05.png" },
{ nome: "Urologia", img: "/img/specialities-01.png" },
];
useEffect(() => {
const handleScroll = () => {
const header = document.querySelector(".landing-header");
if (window.scrollY > 50) {
header.classList.add("scrolled");
} else {
header.classList.remove("scrolled");
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="hospital-landing-root">
{/* HEADER */}
<header className="hospital-landing-header">
<div className="hospital-container hospital-header-content">
<div className="hospital-logo-area">
<img src="/img/logo50.png" alt="Logo Medi-Connect" />
<h1>Medi-Connect</h1>
</div>
<nav className="hospital-nav-links">
<a href="#sobre">Sobre</a>
<a href="#especialidades">Especialidades</a>
<a href="#profissional">Profissionais</a>
<a href="#contato">Contato</a>
<a href="/login" className="hospital-btn-login">
Acessar Sistema
</a>
</nav>
</div>
</header>
{/* HERO - NOVO COM IMAGEM DE FUNDO */}
<section className="hospital-hero-bg-section">
<div className="hospital-hero-overlay">
<div className="hospital-hero-bg-text">
<h1>Cuidamos de Você com Excelência e Tecnologia</h1>
<p>
Atendimento humanizado e especializado sua saúde é nossa
prioridade.
</p>
<a href="/login" className="hospital-btn-primary">
Acessar Sistema
</a>
</div>
</div>
</section>
{/* SOBRE */}
<section id="sobre" className="hospital-about-section">
<div className="hospital-container hospital-about-content">
<div className="hospital-about-image">
<img
src="/img/banner.png"
alt="Interior da clínica"
/>
</div>
<div className="hospital-about-text">
<h3>Sobre Nós</h3>
<p>
Oferecendo atendimento médico de excelência,
com estrutura moderna e uma equipe comprometida com a vida. No
<strong> Medi-Connect</strong>, cada detalhe é pensado para
oferecer segurança, conforto e confiança.
</p>
<div className="hospital-about-highlights">
<div>🏥 Estrutura moderna</div>
<div>👨 Profissionais qualificados</div>
<div>🕓 Atendimento 24h</div>
</div>
</div>
</div>
</section>
{/* ESPECIALIDADES */}
<section id="especialidades" className="hospital-specialities-section">
<div className="hospital-container">
<div className="hospital-section-header">
<h3>Nossas Especialidades</h3>
<p>
Conheça as áreas que fazem do Medi-Connect um centro de referência.
</p>
</div>
<div className="hospital-specialities-grid">
{especialidades.map((esp, i) => (
<div key={i} className="hospital-speciality-card">
<img src={esp.img} alt={esp.nome} />
<h4>{esp.nome}</h4>
</div>
))}
</div>
</div>
</section>
{/* MÉDICO GIGANTE */}
<section id="profissional" className="hospital-doctor-highlight-section">
<div className="hospital-container hospital-doctor-highlight">
<div className="hospital-doctor-image">
<img
src="/img/Doctor-Free-PNG-Image.png"
alt="Médico sorridente"
/>
</div>
<div className="hospital-doctor-text">
<h3>Profissionais Dedicados ao Seu Bem-Estar</h3>
<p>
Nossa equipe médica é formada por especialistas experientes e
comprometidos em oferecer um atendimento humano, empático e de
alta qualidade. Aqui, o cuidado vai muito além do tratamento é
sobre confiança, respeito e dedicação a cada paciente.
</p>
<a href="#contato" className="hospital-btn-primary">
Fale Conosco
</a>
</div>
</div>
</section>
{/* CONTATO */}
<section className="hospital-contact-section" id="contato">
<div className="hospital-container hospital-contact-wrapper">
<div className="hospital-contact-info">
<h3>Entre em Contato</h3>
<p>
Estamos prontos para atender você. Tire suas dúvidas, agende uma consulta ou fale com nossa equipe de atendimento.
</p>
<div className="hospital-contact-cards">
<div className="hospital-contact-item">
<i className="fas fa-phone-alt"></i>
<div>
<h4>Telefone</h4>
<p>(11) 4002-8922</p>
</div>
</div>
<div className="hospital-contact-item">
<i className="fas fa-envelope"></i>
<div>
<h4>Email</h4>
<p>contato@mediconnect.com</p>
</div>
</div>
<div className="hospital-contact-item">
<i className="fas fa-map-marker-alt"></i>
<div>
<h4>Localização</h4>
<p>Av. Paulista, 1000 - São Paulo, SP</p>
</div>
</div>
</div>
</div>
<form className="hospital-contact-form">
<h4>Envie uma mensagem</h4>
<input type="text" placeholder="Seu nome" required />
<input type="email" placeholder="Seu e-mail" required />
<textarea placeholder="Sua mensagem" rows="4" required></textarea>
<button type="submit" className="hospital-btn-primary">Enviar</button>
</form>
</div>
</section>
{/* FOOTER */}
<footer className="hospital-landing-footer">
<div className="hospital-footer-content">
<div className="hospital-footer-logo">
<img src="/img/logo50.png" alt="Medi-Connect" />
<span>Medi-Connect</span>
</div>
<div className="hospital-footer-socials">
<a href="#"><i className="fab fa-facebook-f"></i></a>
<a href="#"><i className="fab fa-instagram"></i></a>
<a href="#"><i className="fab fa-linkedin-in"></i></a>
</div>
<div className="hospital-footer-bottom">
© {new Date().getFullYear()} Medi-Connect Cuidando da sua saúde com excelência.
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,147 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import { useResponsive } from '../../utils/useResponsive';
export default function MagicLink() {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState('');
const [isTouched, setIsTouched] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [serverError, setServerError] = useState('');
const [serverSucsess, setServerSucsess] = useState('');
const tokenUsuario =getAccessToken()
const handleSubmit = async (e) => {
e.preventDefault();
const emailValidation = validateEmail(email);
if (emailValidation) {
// Se houver erros locais, para a execução antes do fetch
return;
}
try {
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify({
email: email,
options: {
emailRedirectTo: "https://mediconnect-neon.vercel.app/"
}
});
var requestOptions = {
method: 'POST',
headers: myHeaders,
body: raw,
redirect: 'follow'
};
const response = await fetch(
`${supabaseUrl}/auth/v1/otp`, requestOptions
);
const result = await response.json();
console.log("🔗 Retorno da API de acesso único:", result);
serverSucsess("Se o e-mail estiver cadastrado, enviamos um link de acesso!");
setEmail("");
} catch (error) {
console.error("❌ Erro ao enviar magic link:", error);
serverError("Erro ao enviar o link de acesso. Tente novamente.");
}
};
const validateEmail = (emailValue) => {
let error = '';
if (emailValue.trim() === '') {
error = 'O e-mail não pode ficar vazio.';
} else if (!emailValue.includes('@') || !emailValue.includes('.')) {
error = 'O e-mail deve conter o símbolo "@" e um ponto (".") seguido por uma extensão.';
}
// Atualiza o estado de erro específico para o email
setEmailError(error);
return error;
};
const handleEmailChange = (e) => {
const newValue = e.target.value;
setEmail(newValue);
if (isTouched) {
validateEmail(newValue); // Valida em tempo real
}
const { name, value } = e.target;
setConta((prev) => ({
...prev,
[name]: value
}));
};
const handleEmailBlur = (e) => {
setIsTouched(true);
validateEmail(e.target.value); // Valida ao perder o foco
};
return (
<div class="login-body">
<div class="container">
<div class="image-section">
<div class="content-section doctor-info">
<div class="image-box doctor-box">
<div class="doctor-image"></div>
</div>
<div class="text-box doctor-text">
<h3>Você mais próximo de seu médico</h3>
<p>Consultas online e acompanhamento em tempo real.</p>
</div>
</div>
<div class="content-section patient-info">
<div class="image-box patient-box">
<div class="patient-image"></div>
</div>
<div class="text-box patient-text">
<h3>Agende sem sair de casa</h3>
<p>O seu atendimento, na medida da sua agenda.</p>
</div>
</div>
</div>
<div class="login-section">
<header class="app-header">
<span class="app-name">MediConnect</span>
</header>
<div class="login-form-container">
<h1 class="login-title">Entre para iniciar a sessão.</h1>
<p class="login-subtitle">Digite seu e-mail para receber um link de acesso seguro.</p>
<form onSubmit={handleSubmit} noValidate>
<label for="email" class="input-label">E-mail</label>
<div class="input-group phone-input">
<input type="email" id="email" name="email" value={email} onChange={handleEmailChange} onBlur={handleEmailBlur} placeholder="seuemail@dominio.com" required></input>
</div>
{emailError && <p style={{ color: 'red', margin: '5px 0' }}>{emailError}</p>}
{serverError && <p style={{ color: 'green', margin: '5px 0' }}>{serverSucsess}</p>}
{serverError && <p style={{ color: 'red', margin: '5px 0' }}>{serverError}</p>}
<button id="button" type="submit" class="login-button">Enviar Link Mágico</button>
<a href="#" class="login-with-code" onClick={() => navigate("/Login")}>Entrar com Senha</a>
</form>
</div>
</div>
</div>
</div>
);
};

234
src/pages/Login/Login.jsx Normal file
View File

@ -0,0 +1,234 @@
import { useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import ReCAPTCHA from "react-google-recaptcha";
import { setUserId, setUserEmail, setUserRole, setDoctorId, setPatientId, setFullName } from "../../utils/userInfo";
import "../../assets/css/login.css";
export default function Login() {
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [isTouched, setIsTouched] = useState(false);
const [serverError, setServerError] = useState('');
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const navigate = useNavigate();
const [conta, setConta] = useState({ email: "", password: "" });
const [showPassword, setShowPassword] = useState(false);
const recaptchaRef = useRef();
const togglePasswordVisibility = () => setShowPassword(prev => !prev);
const validateEmail = (emailValue) => {
let error = '';
if (emailValue.trim() === '') error = 'O e-mail não pode ficar vazio.';
else if (!emailValue.includes('@') || !emailValue.includes('.')) error = 'O e-mail deve conter "@" e um ponto.';
setEmailError(error);
return error;
};
const validatePassword = (passwordValue) => {
let error = '';
const MIN_LENGTH = 3;
if (passwordValue.trim() === '') error = 'A senha não pode ficar vazia.';
else if (passwordValue.length < MIN_LENGTH) error = `A senha deve ter pelo menos ${MIN_LENGTH} caracteres.`;
setPasswordError(error);
return error;
};
const handleEmailChange = (e) => {
const { name, value } = e.target;
setConta(prev => ({ ...prev, [name]: value }));
setEmail(value);
if (isTouched) validateEmail(value);
};
const handleEmailBlur = (e) => {
setIsTouched(true);
validateEmail(e.target.value);
};
const handlePasswordChange = (e) => {
const { name, value } = e.target;
setConta(prev => ({ ...prev, [name]: value }));
setPassword(value);
if (isTouched) validatePassword(value);
};
const handlePasswordBlur = (e) => {
setIsTouched(true);
validatePassword(e.target.value);
};
const handleLogin = async (e) => {
e.preventDefault();
const recaptchaValue = recaptchaRef.current.getValue();
if (!recaptchaValue) {
setServerError("Por favor, confirme que você não é um robô.");
return;
}
setServerError('');
setEmailError('');
setPasswordError('');
if (validateEmail(conta.email) || validatePassword(conta.password)) return;
try {
const loginResp = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { "apikey": supabaseAK, "Content-Type": "application/json" },
body: JSON.stringify({ email: conta.email, password: conta.password, grant_type: "password" }),
redirect: "follow"
});
const loginResult = await loginResp.json();
if (!loginResult.access_token) {
setServerError("Credenciais inválidas. Verifique seu e-mail e senha.");
return;
}
localStorage.setItem("access_token", loginResult.access_token);
localStorage.setItem("refresh_token", loginResult.refresh_token);
const userInfoRes = await fetch(`${supabaseUrl}/functions/v1/user-info`, {
method: "GET",
headers: { "Authorization": `Bearer ${loginResult.access_token}`, "apikey": supabaseAK, "Content-Type": "application/json" },
redirect: "follow"
});
const userInfo = await userInfoRes.json();
const userData = {
id: userInfo.profile?.id,
email: userInfo.user?.email,
role: userInfo.roles || [],
doctor_id: userInfo.profile?.doctor_id || userInfo.doctor_id || null,
patient_id: userInfo.profile?.patient_id || userInfo.patient_id || null,
full_name: userInfo.profile?.full_name || userInfo.user?.user_metadata?.full_name || userInfo.user?.email?.split('@')[0] || null
};
if (userData.id) {
setUserId(userData.id);
setUserEmail(userData.email);
if (userData.doctor_id) setDoctorId(userData.doctor_id);
if (userData.patient_id) setPatientId(userData.patient_id);
if (userData.full_name) setFullName(userData.full_name);
}
const rolePriority = [
{ role: "admin", path: "/admin/dashboard" },
{ role: "secretaria", path: "/secretaria/" },
{ role: "medico", path: "/medico/dashboard" },
{ role: "user", path: "/patientapp" },
{ role: "paciente", path: "/paciente" },
];
const matchedRole = rolePriority.find(r => userData.role.includes(r.role));
if (matchedRole) {
setUserRole(matchedRole.role);
navigate(matchedRole.path);
} else {
setServerError("Usuário sem função atribuída. Contate o administrador.");
}
} catch (error) {
setServerError("Erro ao conectar ao servidor. Tente novamente.");
}
};
return (
<div className="login-body">
<div className="container">
<div className="image-section">
<div className="content-section doctor-info">
<div className="image-box doctor-box">
<div className="doctor-image"></div>
</div>
<div className="text-box doctor-text">
<h3>Você mais próximo de seu médico</h3>
<p>Consultas online e acompanhamento em tempo real.</p>
</div>
</div>
<div className="content-section patient-info">
<div className="image-box patient-box">
<div className="patient-image"></div>
</div>
<div className="text-box patient-text">
<h3>Agende sem sair de casa</h3>
<p>O seu atendimento, na medida da sua agenda.</p>
</div>
</div>
</div>
<div className="login-section">
<header className="app-header">
<span className="app-name">MediConnect</span>
</header>
<div className="login-form-container">
<h1 className="login-title">Entre para iniciar a sessão.</h1>
<form onSubmit={handleLogin} noValidate>
<label htmlFor="email" className="input-label">E-mail</label>
<div className="input-group phone-input">
<input
type="email"
id="email"
name="email"
value={conta.email}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
placeholder="seuemail@dominio.com"
required
/>
</div>
{emailError && <p style={{ color: 'red' }}>{emailError}</p>}
<label htmlFor="password" className="input-label">Senha</label>
<div className="input-group password-input">
<i className="fas fa-lock input-icon"></i>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={conta.password}
onChange={handlePasswordChange}
onBlur={handlePasswordBlur}
required
/>
<i
onClick={togglePasswordVisibility}
className={`toggle-password fas ${showPassword ? 'fa-eye' : 'fa-eye-slash'}`}
style={{ cursor: 'pointer' }}
></i>
</div>
{passwordError && <p style={{ color: 'red', margin: '5px 0' }}>{passwordError}</p>}
{serverError && <p style={{ color: 'red', margin: '5px 0' }}>{serverError}</p>}
<a href="#" className="reset-password">Esqueceu a senha?</a>
{/* ✅ RECAPTCHA */}
<div className="recaptcha-wrapper">
<ReCAPTCHA
sitekey="6LelHhAsAAAAAKABZAIDGDXiO1OqIR9KNblghRvt"
ref={recaptchaRef}
/>
</div>
<button id="button" type="submit" className="login-button">Entrar</button>
<a href="#" className="login-with-code" onClick={() => navigate("/AcessoUnico")}>
Entrar com Link de Acessso Único
</a>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,264 +0,0 @@
// PatientList.jsx
import { Link } from "react-router-dom";
import "../../assets/css/index.css";
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import supabase from "../../Supabase"; // se for usar supabase para delete, senão pode remover
// Componente que renderiza o menu em um portal (document.body) e posiciona em relação ao botão
function DropdownPortal({ anchorEl, isOpen, onClose, className, children }) {
const menuRef = useRef(null);
const [stylePos, setStylePos] = useState({
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
zIndex: 1000,
});
// Posiciona o menu após renderar (medir tamanho do menu)
useLayoutEffect(() => {
if (!isOpen) return;
if (!anchorEl || !menuRef.current) return;
const anchorRect = anchorEl.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// tenta alinhar à direita do botão (como dropdown-menu-right)
let left = anchorRect.right + scrollX - menuRect.width;
let top = anchorRect.bottom + scrollY;
// evita sair da esquerda da tela
if (left < 0) left = scrollX + 4;
// se extrapolar bottom, abre para cima
if (top + menuRect.height > window.innerHeight + scrollY) {
top = anchorRect.top + scrollY - menuRect.height;
}
setStylePos({
position: "absolute",
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
visibility: "visible",
zIndex: 1000,
});
}, [isOpen, anchorEl, children]);
// fecha ao clicar fora / ao rolar
useEffect(() => {
if (!isOpen) return;
function handleDocClick(e) {
const menu = menuRef.current;
if (menu && !menu.contains(e.target) && anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleDocClick);
// captura scroll em qualquer elemento (true)
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleDocClick);
document.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen, onClose, anchorEl]);
if (!isOpen) return null;
return createPortal(
<div
ref={menuRef}
className={className} // mantém as classes que você já usa no CSS
style={stylePos}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
function PatientList() {
const [search, setSearch] = useState("");
const [patients, setPatients] = useState([]);
const [openDropdown, setOpenDropdown] = useState(null);
const anchorRefs = useRef({}); // guarda referência do botão de cada linha
var requestOptions = {
method: "GET",
redirect: "follow",
};
useEffect(() => {
fetch("https://mock.apidog.com/m1/1053378-0-default/pacientes", requestOptions)
.then((response) => response.json())
.then((result) => {
console.log("API result:", result);
setPatients(result.data || []);
})
.catch((error) => console.log("error", error));
}, []);
// Exemplo simples de delete local (confirmação + remove do state)
const handleDelete = async (id) => {
const confirmDel = window.confirm("Tem certeza que deseja excluir este paciente?");
if (!confirmDel) return;
const requestOptions = {
method: 'DELETE',
redirect: 'follow'
};
fetch("https://mock.apidog.com/m1/1053378-0-default/pacientes/", requestOptions)
.then(response => response.text())
.then(result => console.log(result))
.catch(error => console.log('error', error));
// Se quiser apagar no supabase, faça a chamada aqui.
// const { error } = await supabase.from("Patient").delete().eq("id", id);
// if (error) { console.error(error); return; }
setPatients((prev) => prev.filter((p) => p.id !== id));
setOpenDropdown(null);
};
const filteredPatients = patients.filter((p) => {
if (!p) return false;
const nome = (p.nome || "").toLowerCase();
const cpf = (p.cpf || "").toLowerCase();
const email = (p.email || "").toLowerCase();
const q = search.toLowerCase();
return nome.includes(q) || cpf.includes(q) || email.includes(q);
});
const mascararCPF = (cpf = "") => {
if (cpf.length < 5) return cpf;
const inicio = cpf.slice(0, 3);
const fim = cpf.slice(-2);
return `${inicio}.***.***-${fim}`;
};
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row ">
<div className="col-sm-4 col-3">
<h4 className="page-title">Lista de Pacientes</h4>
<input
type="text"
className="form-control"
placeholder="🔍 Buscar pacientes"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<br />
</div>
<div className="col-sm-8 col-9 text-right m-b-20">
<Link to="/patient" className="btn btn-primary btn-rounded">
<i className="fa fa-plus"></i> Adicionar Paciente
</Link>
</div>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table datatable mb-0">
<thead>
<tr>
<th>Nome</th>
<th>Cpf</th>
<th>Data de Nascimento</th>
<th>Telefone</th>
<th>Email</th>
<th>Status</th>
<th className="text-right">Ações</th>
</tr>
</thead>
<tbody>
{filteredPatients.length > 0 ? (
filteredPatients.map((p) => (
<tr key={p.id}>
<td>{p.nome}</td>
<td>{mascararCPF(p.cpf)}</td>
<td>{p.data_nascimento}</td>
<td>{p.telefone}</td>
<td>{p.email}</td>
<td>{p.status}</td>
<td className="text-right">
<div className="dropdown dropdown-action" style={{ display: "inline-block" }}>
<button
type="button"
ref={(el) => (anchorRefs.current[p.id] = el)}
className="action-icon"
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown === p.id ? null : p.id);
}}
>
<i className="fa fa-ellipsis-v"></i>
</button>
<DropdownPortal
anchorEl={anchorRefs.current[p.id]}
isOpen={openDropdown === p.id}
onClose={() => setOpenDropdown(null)}
className="dropdown-menu dropdown-menu-right show"
>
{/*<Link
className="dropdown-item-custom"
to={`/profilepatient/${p.id}`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
>
<i className="fa fa-eye"></i> Ver Detalhes
</Link>*/}
<Link
className="dropdown-item-custom"
to={`/editpatient/${p.id}`}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(null);
}}
>
<i className="fa fa-pencil m-r-5"></i> Editar
</Link>
<button
className="dropdown-item-custom dropdown-item-delete"
onClick={() => handleDelete(p.id)}
>
<i className="fa fa-trash-o m-r-5"></i> Excluir
</button>
</DropdownPortal>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="text-center text-muted">
Nenhum paciente encontrado
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default PatientList;
;

View File

@ -0,0 +1,561 @@
import React, { useState, useEffect } from 'react';
import {
Container,
Grid,
Card,
CardContent,
Typography,
Button,
Avatar,
Box,
Chip,
CircularProgress,
Alert,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stepper,
Step,
StepLabel,
Checkbox,
FormControlLabel
} from '@mui/material';
import {
ArrowBack,
CalendarToday,
AccessTime,
Person,
LocalHospital,
CheckCircle,
Email,
Sms
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import { Link } from "react-router-dom";
import Swal from "sweetalert2";
import { getAccessToken } from "../../utils/auth.js";
import { getPatientId } from "../../utils/userInfo";
import { getUserRole } from '../../utils/userInfo';
const AgendarConsulta = () => {
const { medicoId } = useParams();
const navigate = useNavigate();
const [medico, setMedico] = useState(null);
const [horariosDisponiveis, setHorariosDisponiveis] = useState([]);
const [loading, setLoading] = useState(true);
const [dataSelecionada, setDataSelecionada] = useState('');
const [horarioSelecionado, setHorarioSelecionado] = useState(null);
const [modalConfirmacao, setModalConfirmacao] = useState(false);
const [agendando, setAgendando] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [enviarEmail, setEnviarEmail] = useState(true);
const [enviarSMS, setEnviarSMS] = useState(true);
const [minDate, setMinDate] = useState("");
const [carregandoHorarios, setCarregandoHorarios] = useState(false);
const [formData, setFormData] = useState({
scheduled_date: "",
scheduled_time: "",
chief_complaint: "",
patient_notes: ""
});
let [confirmationModal, setConfirmationModal] = useState(false);
const role = getUserRole();
const tokenUsuario = getAccessToken();
const patientId = getPatientId();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const headers = {
"Content-Type": "application/json",
apikey: supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
};
const handleConfirmationModal = async () => {
if (!dataSelecionada || !horarioSelecionado) {
alert("Selecione uma data e horário válidos");
return;
}
const confirm = window.confirm(`
Confirmar agendamento:
Médico: Dr. ${medico?.nome}
Especialidade: ${medico?.especialidade}
Data: ${new Date(dataSelecionada).toLocaleDateString('pt-BR')}
Horário: ${horarioSelecionado ? horarioSelecionado.datetime.split("T")[1].substring(0, 5) : ''}
Valor: R$ ${medico?.valorConsulta}
Deseja confirmar?
`);
if (confirm) {
await confirmarAgendamento();
alert(`Consulta marcada com sucesso! Sua consulta com Dr. ${medico.nome} foi agendada.`);
navigate(`/${role}/consultalist`);
}
};
useEffect(() => {
const getToday = () => {
const today = new Date();
const offset = today.getTimezoneOffset();
today.setMinutes(today.getMinutes() - offset);
return today.toISOString().split("T")[0];
};
setMinDate(getToday());
}, []);
useEffect(() => {
carregarMedicoEHorarios();
}, [medicoId]);
const carregarMedicoEHorarios = async () => {
setLoading(true);
try {
// Buscar dados do médico
const medicoResponse = await fetch(
`${supabaseUrl}/rest/v1/doctors?id=eq.${medicoId}`,
{ headers }
);
if (medicoResponse.ok) {
const medicoData = await medicoResponse.json();
if (medicoData.length > 0) {
const doctorData = medicoData[0];
setMedico({
id: doctorData.id,
nome: doctorData.full_name,
especialidade: doctorData.specialty,
valorConsulta: 250, // Valor fixo por enquanto
foto: '',
biografia: doctorData.bio || 'Especialista em ' + doctorData.specialty
});
} else {
throw new Error('Médico não encontrado');
}
} else {
throw new Error('Erro ao carregar dados do médico');
}
setLoading(false);
} catch (error) {
console.error('Erro ao carregar dados:', error);
setMedico(medicoMock);
setLoading(false);
}
};
// Função para buscar horários disponíveis
const fetchHorariosDisponiveis = async (date) => {
if (!medicoId || !date) {
setHorariosDisponiveis([]);
return;
}
setCarregandoHorarios(true);
const startDate = `${date}T00:00:00.000Z`;
const endDate = `${date}T23:59:59.999Z`;
const payload = {
doctor_id: medicoId,
start_date: startDate,
end_date: endDate,
appointment_type: "presencial",
};
try {
const response = await fetch(
`${supabaseUrl}/functions/v1/get-available-slots`,
{
method: "POST",
headers,
body: JSON.stringify(payload),
}
);
const data = await response.json();
console.log("🔍 AgendarConsultas - Resposta da Edge Function:", data);
if (!response.ok) throw new Error(data.error || "Erro ao buscar horários");
// Usar exatamente o mesmo formato do AgendaForm
const slotsDisponiveis = (data?.slots || []).filter((s) => s.available);
console.log("✅ Slots disponíveis após filtro:", slotsDisponiveis);
console.log("🔍 Todos os slots (antes do filtro):", data?.slots);
console.log("❌ Slots NÃO disponíveis:", (data?.slots || []).filter((s) => !s.available));
console.log("✅ AgendarConsultas - Slots disponíveis após filtro:", slotsDisponiveis);
setHorariosDisponiveis(slotsDisponiveis);
if (slotsDisponiveis.length === 0) {
alert("Nenhum horário disponível para este dia.");
}
} catch (error) {
console.error("Erro ao buscar horários disponíveis:", error);
setHorariosDisponiveis([]);
alert("Não foi possível obter os horários disponíveis.");
} finally {
setCarregandoHorarios(false);
}
};
// Atualizar horários quando a data muda
useEffect(() => {
if (dataSelecionada && medicoId) {
fetchHorariosDisponiveis(dataSelecionada);
}
}, [dataSelecionada, medicoId]);
const selecionarHorario = (horario) => {
setHorarioSelecionado(horario);
setModalConfirmacao(true);
setActiveStep(0);
};
const confirmarAgendamento = async () => {
setAgendando(true);
try {
if (!horarioSelecionado || !horarioSelecionado.datetime) {
throw new Error("Horário não selecionado corretamente");
}
// Usar exatamente o mesmo formato que o AgendaForm
const scheduled_at = horarioSelecionado.datetime;
const payload = {
patient_id: patientId,
doctor_id: medicoId,
scheduled_at,
duration_minutes: 30,
appointment_type: "presencial",
chief_complaint: formData.chief_complaint || "Consulta agendada pelo paciente",
patient_notes: formData.patient_notes || "",
created_by: patientId,
};
const response = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
{
method: "POST",
headers: {
...headers,
Prefer: "return=representation",
},
body: JSON.stringify(payload),
}
);
if (response.ok) {
const consultaCriada = await response.json();
console.log("Consulta criada:", consultaCriada);
setActiveStep(2);
setAgendando(false);
// Aqui você pode adicionar envio de SMS se necessário
// if (enviarSMS) {
// await sendSMS(telefone, mensagem, patientId);
// }
} else {
const error = await response.json();
console.error("Erro da API:", error);
throw new Error("Não foi possível criar a consulta");
}
} catch (error) {
console.error('Erro no agendamento:', error);
alert(error.message || "Erro ao realizar agendamento. Tente novamente.");
setAgendando(false);
}
};
const finalizarAgendamento = () => {
setModalConfirmacao(false);
navigate(`/${role}/consultalist`);
};
// Não precisamos mais da linha datasDisponiveis, pois usamos a Edge Function
const horariosDaData = horariosDisponiveis.filter(h => h.data === dataSelecionada);
const renderStepContent = (step) => {
switch (step) {
case 0:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Confirme os dados da consulta:
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Person sx={{ mr: 1, color: 'primary.main' }} />
<Typography><strong>Médico:</strong> Dr. {medico.nome}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<LocalHospital sx={{ mr: 1, color: 'primary.main' }} />
<Typography><strong>Especialidade:</strong> {medico.especialidade}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CalendarToday sx={{ mr: 1, color: 'primary.main' }} />
<Typography><strong>Data:</strong> {new Date(dataSelecionada).toLocaleDateString('pt-BR')}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AccessTime sx={{ mr: 1, color: 'primary.main' }} />
<Typography><strong>Horário:</strong> {horarioSelecionado ? horarioSelecionado.datetime.split("T")[1].substring(0, 5) : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography><strong>Valor:</strong> R$ {medico.valorConsulta}</Typography>
</Box>
</Paper>
<Alert severity="info">
Chegue com 15 minutos de antecedência para o atendimento.
</Alert>
</Box>
);
case 1:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Escolha como deseja receber as confirmações:
</Typography>
<FormControlLabel
control={
<Checkbox
checked={enviarEmail}
onChange={(e) => setEnviarEmail(e.target.checked)}
icon={<Email />}
checkedIcon={<Email color="primary" />}
/>
}
label="Receber confirmação por E-mail"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={
<Checkbox
checked={enviarSMS}
onChange={(e) => setEnviarSMS(e.target.checked)}
icon={<Sms />}
checkedIcon={<Sms color="primary" />}
/>
}
label="Receber confirmação por SMS"
sx={{ display: 'block' }}
/>
<Alert severity="info" sx={{ mt: 2 }}>
Você também receberá um lembrete 24 horas antes da consulta.
</Alert>
</Box>
);
case 2:
return (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<CheckCircle sx={{ fontSize: 60, color: 'success.main', mb: 2 }} />
<Typography variant="h5" gutterBottom color="success.main">
Consulta Agendada com Sucesso!
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
Sua consulta foi agendada para {new Date(dataSelecionada).toLocaleDateString('pt-BR')} às {horarioSelecionado ? horarioSelecionado.datetime.split("T")[1].substring(0, 5) : ''}
</Typography>
<Alert severity="success">
A consulta foi adicionada à agenda do Dr. {medico.nome} e as confirmações foram enviadas.
</Alert>
</Box>
);
default:
return null;
}
};
if (loading) {
return (
<Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Carregando horários...</Typography>
</Container>
);
}
if (!medico) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">Médico não encontrado</Alert>
</Container>
);
}
return (
<div className='page-wrapper'>
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate("/paciente/medicosdisponiveis")}
className='btn btn-secondary'
>
Voltar para Médicos
</Button>
{/* Cabeçalho do Médico */}
<Card sx={{ mb: 4 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar
sx={{
width: 60,
height: 60,
mr: 2,
bgcolor: 'primary.main',
fontSize: '1.5rem',
fontWeight: 'bold'
}}
>
{medico.nome.split(' ').map(n => n[0]).join('')}
</Avatar>
<Box>
<Typography variant="h5" fontWeight="bold">
Dr. {medico.nome}
</Typography>
<Chip label={medico.especialidade} color="primary" size="small" />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{medico.biografia}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
<form>
<hr />
<h3>Informações do atendimento</h3>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Data<span className="text-danger">*</span></label>
<div>
<input
type="date"
className="form-control"
min={minDate}
value={dataSelecionada}
onChange={(e) => setDataSelecionada(e.target.value)}
/>
</div>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Horários Disponíveis<span className="text-danger">*</span></label>
<div>
<select
className="form-control"
value={horarioSelecionado ? horarioSelecionado.datetime.split("T")[1].substring(0, 5) : ""}
onChange={(e) => {
const horaValue = e.target.value;
const horario = horariosDisponiveis.find(slot => {
const hora = slot.datetime.split("T")[1].substring(0, 5);
return hora === horaValue;
});
setHorarioSelecionado(horario);
}}
disabled={carregandoHorarios || !horariosDisponiveis.length}
>
<option value="">
{carregandoHorarios
? "Carregando horários..."
: horariosDisponiveis.length
? "Selecione um horário"
: "Nenhum horário disponível"}
</option>
{horariosDisponiveis.map((slot) => {
const hora = slot.datetime.split("T")[1].substring(0, 5);
return (
<option key={slot.datetime} value={hora}>
{hora}
</option>
);
})}
</select>
</div>
</div>
</div>
</div>
<div className="form-group">
<label>Motivo da consulta</label>
<input
type="text"
className="form-control"
value={formData.chief_complaint}
onChange={(e) => setFormData(prev => ({ ...prev, chief_complaint: e.target.value }))}
placeholder="Ex: Dor no peito, consulta de rotina..."
/>
</div>
<div className="form-group">
<label>Observações</label>
<textarea
cols="30"
rows="4"
className="form-control"
value={formData.patient_notes}
onChange={(e) => setFormData(prev => ({ ...prev, patient_notes: e.target.value }))}
placeholder="Observações adicionais (opcional)"
></textarea>
</div>
<div className="form-group">
<label className="display-block">Status da consulta</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
name="status"
id="product_active"
value="option1"
defaultChecked
/>
<label
className="form-check-label"
htmlFor="product_active"
>
Receber confirmação por SMS
</label>
</div>
</div>
<div className="m-t-20 text-center">
<button
className="btn btn-primary submit-btn"
type="button"
onClick={handleConfirmationModal}
disabled={agendando || !dataSelecionada || !horarioSelecionado}
>
{agendando ? "Agendando..." : "Marcar consulta"}
</button>
</div>
</form>
</Container>
</div>
);
};
export default AgendarConsulta;

View File

@ -0,0 +1,306 @@
import React, { useState, useEffect } from 'react';
import { getAccessToken } from '../../utils/auth';
import {
Container,
Grid,
Card,
CardContent,
Typography,
Button,
Chip,
Avatar,
Box,
Rating,
CircularProgress,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Paper,
InputAdornment,
Divider,
Alert
} from '@mui/material';
import {
ArrowBack,
CalendarToday,
AccessTime,
MedicalServices,
Search,
FilterList
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
const AvatarForm = "/img/AvatarForm.jpg";
import { getUserRole } from '../../utils/userInfo';
const MedicosDisponiveis = () => {
const [medicos, setMedicos] = useState([]);
const [medicosFiltrados, setMedicosFiltrados] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [specialtyFilter, setSpecialtyFilter] = useState('');
const [avaliacaoFilter, setAvaliacaoFilter] = useState('');
const [valorFilter, setValorFilter] = useState('');
const navigate = useNavigate();
const role = getUserRole();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const tokenUsuario = getAccessToken()
var myHeaders = new Headers();
myHeaders.append(
"apikey",
supabaseAK
);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
var requestOptions = {
method: "GET",
headers: myHeaders,
redirect: "follow",
};
// buscar médicos
useEffect(() => {
fetch(`${supabaseUrl}/rest/v1/doctors`, requestOptions)
.then((response) => response.json())
.then((result) => setMedicos(Array.isArray(result) ? result : []))
.catch((error) => console.log("error", error));
}, []);
useEffect(() => {
carregarMedicos();
}, []);
useEffect(() => {
aplicarFiltros();
}, [medicos, searchTerm, specialtyFilter, avaliacaoFilter, valorFilter]);
const carregarMedicos = async () => {
setLoading(true);
try {
setTimeout(() => {
setLoading(false);
}, 1000);
} catch (error) {
console.error('Erro ao carregar médicos:', error);
// Fallback para dados mock em caso de erro
setLoading(false);
}
};
const specialty = Array.from(new Set(medicos.map(m => m.specialty).filter(Boolean)));
const aplicarFiltros = () => {
let filtrados = [...medicos];
if (searchTerm) {
filtrados = filtrados.filter(medico => {
const nome = medico.full_name ? medico.full_name.toLowerCase() : "";
const especialidade = medico.specialty ? medico.specialty.toLowerCase() : "";
return (
nome.includes(searchTerm.toLowerCase()) ||
especialidade.includes(searchTerm.toLowerCase())
);
});
}
if (specialtyFilter) {
filtrados = filtrados.filter(medico =>
medico.specialty === specialtyFilter
);
}
setMedicosFiltrados(filtrados);
};
const limparFiltros = () => {
setSearchTerm('');
setSpecialtyFilter('');
setAvaliacaoFilter('');
setValorFilter('');
};
const verHorariosDisponiveis = (medicoId) => {
navigate(`/${role}/agendarconsulta/${medicoId}`);
};
if (loading) {
return (
<Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Carregando médicos...</Typography>
</Container>
);
}
return (
<div className='page-wrapper'>
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
Médicos Disponíveis
</Typography>
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 4 }}>
Encontre o médico perfeito para sua necessidade - {medicosFiltrados.length} médico(s) encontrado(s)
</Typography>
{/* Filtros e Busca */}
<Paper sx={{ p: 3, mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<FilterList sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">Filtros e Busca</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<TextField
fullWidth
placeholder="Buscar médico ou especialidade..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item xs={12} md={2}>
<FormControl fullWidth>
<InputLabel>Especialidade</InputLabel>
<Select
value={specialtyFilter}
label="Especialidade"
onChange={(e) => setSpecialtyFilter(e.target.value)}
>
<MenuItem value="">Todas</MenuItem>
{specialty.map(esp => (
<MenuItem key={esp} value={esp}>{esp}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={2}>
<Button
variant="outlined"
fullWidth
onClick={limparFiltros}
sx={{ height: '56px' }}
>
Limpar Filtros
</Button>
</Grid>
</Grid>
</Paper>
{/* Lista de Médicos */}
<Grid container spacing={3} justifyContent="center" alignItems="stretch">
{medicosFiltrados.map((medico) => (
<Grid item key={medico.id}>
<Card
sx={{
width: 250,
height: 260,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
borderRadius: 3,
boxShadow: 2,
transition: '0.3s',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 5,
},
}}
>
<CardContent
sx={{
flexGrow: 1,
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{/* Parte superior: avatar + texto */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'primary.main',
fontSize: '2rem',
fontWeight: 'bold',
mb: 1,
}}
>
<img
alt={medico.full_name.split(' ').map((n) => n[0]).join('')}
src={AvatarForm}
style={{ width: '80px', height: '80px', objectFit: 'cover' }}>
</img>
</Avatar>
<Typography variant="h6" fontWeight="bold">
{medico.full_name}
</Typography>
<Chip
icon={<MedicalServices />}
label={medico.specialty}
color="primary"
variant="outlined"
size="small"
sx={{ mt: 1 }}
/>
</Box>
{/* Botão na parte inferior */}
<Button
variant="contained"
onClick={() => verHorariosDisponiveis(medico.id)}
sx={{
borderRadius: 2,
mt: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
}}
>
<CalendarToday sx={{ fontSize: 22 }}/>
Marcar Consulta
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{medicosFiltrados.length === 0 && !loading && (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary">
Nenhum médico encontrado com os filtros selecionados
</Typography>
<Button onClick={limparFiltros} sx={{ mt: 2 }}>
Limpar filtros
</Button>
</Paper>
)}
</Container>
</div>
);
};
export default MedicosDisponiveis;

View File

@ -0,0 +1,80 @@
import { Outlet, NavLink, useLocation } from "react-router-dom";
import './../../assets/css/index.css'
import { useState } from "react";
import { getAccessToken} from "../../utils/auth";
import { getUserRole } from '../../utils/userInfo.js';
import Sidebar from "../../components/layouts/Sidebar.jsx";
export default function PatientApp() {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// 2. Adicione a função para alternar o estado
const toggleSidebar = () => {
setSidebarOpen(!isSidebarOpen);
};
// 3. Crie a string de classe que será aplicada dinamicamente
const mainWrapperClass = isSidebarOpen ? 'main-wrapper sidebar-open' : 'main-wrapper';
// Função para verificar se a rota está ativa
const isActive = (path) => {
const currentPath = location.pathname;
// Verificação exata primeiro
if (currentPath === path) return true;
// Verificação de subrotas (ex: /patientapp/meuslaudos/view/123)
if (currentPath.startsWith(path + '/')) return true;
// Verificações específicas para páginas de edição/criação
if (path === '/patientapp/medicosdisponiveis' && (
currentPath.includes('/patientapp/agendar/') ||
currentPath.includes('/patientapp/consultaform')
)) return true;
if (path === '/patientapp/minhasconsultas' && (
currentPath.includes('/patientapp/consulta/') ||
currentPath.includes('/patientapp/editconsulta/')
)) return true;
if (path === '/patientapp/meuslaudos' && (
currentPath.includes('/patientapp/laudo/') ||
currentPath.includes('/patientapp/viewlaudo/')
)) return true;
return false;
};
const token = getAccessToken();
const user = getUserRole();
// Verificação de autenticação
if (!token) {
return <Navigate to="/login" replace />;
}
// Verificação de role
if (user !== 'paciente') {
return (
<div className="page-wrapper">
<div className="content">
<div className="alert alert-danger text-center">
<h4> Acesso Negado</h4>
<p>Apenas administradores podem acessar esta área.</p>
<button
className="btn btn-primary"
onClick={() => window.history.back()}
>
Voltar
</button>
</div>
</div>
</div>
);
}
return (
<div>
<Sidebar />
<Outlet />
</div>
);
}

View File

@ -0,0 +1,887 @@
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import { getFullName, getUserId } from "../../utils/userInfo";
import "../../assets/css/index.css";
import { getUserRole } from "../../utils/userInfo";
const AvatarForm = "/img/AvatarForm.jpg";
const banner = "/img/banner.png";
export default function PatientDashboard() {
const [appointments, setAppointments] = useState([]);
const [reports, setReports] = useState([]);
const [nextConsultations, setNextConsultations] = useState([]);
const [recentExams, setRecentExams] = useState([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const role = getUserRole();
const tokenUsuario = getAccessToken();
const userId = getUserId();
const patientName = getFullName() || "Paciente";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const API_KEY = supabaseAK;
const requestOptions = {
method: "GET",
headers: {
apikey: API_KEY,
Authorization: `Bearer ${tokenUsuario}`,
},
redirect: "follow",
};
useEffect(() => {
const loadPatientData = async () => {
try {
setLoading(true);
console.log("🔄 Carregando dados do paciente...", { userId, tokenUsuario: !!tokenUsuario });
// Buscar todas as consultas primeiro (sem filtrar por patient_id se não existir na tabela)
const appointmentsResponse = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
requestOptions
);
const reportsResponse = await fetch(
`${supabaseUrl}/rest/v1/reports`,
requestOptions
);
const doctorsResponse = await fetch(
`${supabaseUrl}/rest/v1/doctors?select=id,full_name`,
requestOptions
);
console.log("📡 Status das respostas:", {
appointments: appointmentsResponse.status,
reports: reportsResponse.status,
doctors: doctorsResponse.status
});
const [appointmentsData, reportsData, doctorsData] = await Promise.all([
appointmentsResponse.json(),
reportsResponse.json(),
doctorsResponse.json()
]);
console.log("📊 Dados recebidos:", {
appointments: appointmentsData,
reports: reportsData,
doctors: doctorsData
});
const appointmentsArr = Array.isArray(appointmentsData) ? appointmentsData : [];
const reportsArr = Array.isArray(reportsData) ? reportsData : [];
const doctorsArr = Array.isArray(doctorsData) ? doctorsData : [];
// Filtrar consultas por patient_id (se o campo existir)
const patientAppointments = appointmentsArr.filter(apt =>
apt.patient_id === userId ||
apt.patient_id === parseInt(userId) ||
// Se não tiver patient_id, mostrar algumas para demonstração
!apt.patient_id
);
// Filtrar relatórios por patient_id (se o campo existir)
const patientReports = reportsArr.filter(rep =>
rep.patient_id === userId ||
rep.patient_id === parseInt(userId) ||
// Se não tiver patient_id, mostrar alguns para demonstração
!rep.patient_id
);
// Enriquecer consultas com nomes dos médicos
const enrichedAppointments = patientAppointments.map(appointment => {
const doctor = doctorsArr.find(doc => doc.id === appointment.doctor_id);
return {
...appointment,
doctor_name: doctor ? doctor.full_name : 'Médico não informado'
};
});
console.log("✅ Dados processados:", {
enrichedAppointments,
patientReports,
totalDoctors: doctorsArr.length
});
setAppointments(enrichedAppointments);
setReports(patientReports);
// Processar dados
console.log("🔥 TESTE: Chamando processNextConsultations com:", enrichedAppointments.length, "consultas");
// FORÇAR para teste
if (enrichedAppointments.length === 0) {
forceShowConsultations();
} else {
// Filtrar consultas não canceladas
const nonCancelledConsultations = enrichedAppointments.filter(apt =>
apt.status !== 'cancelled' &&
apt.status !== 'cancelada' &&
apt.status !== 'canceled'
);
console.log("📋 Consultas não canceladas:", nonCancelledConsultations.length, "de", enrichedAppointments.length);
if (nonCancelledConsultations.length > 0) {
// Ordenar por proximidade da data atual (mais próximas primeiro)
const today = new Date();
today.setHours(0, 0, 0, 0);
const sortedByProximity = nonCancelledConsultations
.map(apt => {
const dateField = apt.scheduled_at || apt.date;
const timeField = apt.time;
if (dateField) {
let consultationDateTime;
if (dateField.includes('T')) {
// Data já inclui horário
consultationDateTime = new Date(dateField);
} else {
// Combinar data com horário
consultationDateTime = new Date(dateField);
if (timeField) {
const [hours, minutes] = timeField.split(':');
consultationDateTime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
} else {
consultationDateTime.setHours(12, 0, 0, 0); // Default meio-dia se não houver horário
}
}
const now = new Date();
const diffInMinutes = Math.abs((consultationDateTime - now) / (1000 * 60));
return { ...apt, proximityScore: diffInMinutes, consultationDateTime };
}
return { ...apt, proximityScore: 999999 }; // Consultas sem data vão para o final
})
.sort((a, b) => a.proximityScore - b.proximityScore)
.slice(0, 2);
console.log("✅ Mostrando 2 consultas mais próximas da data atual:", sortedByProximity);
setNextConsultations(sortedByProximity);
} else {
console.log("⚠️ Todas as consultas estão canceladas - usando dados de teste");
forceShowConsultations();
}
}
processRecentExams(patientReports);
} catch (error) {
console.error("❌ Erro ao carregar dados do paciente:", error);
} finally {
setLoading(false);
}
};
if (tokenUsuario) {
loadPatientData();
}
}, [userId, tokenUsuario]);
// Processar próximas consultas
const processNextConsultations = (appointments) => {
console.log("🔄 Processando consultas:", appointments);
console.log("📊 Total de consultas recebidas:", appointments.length);
// Análise detalhada de cada consulta
appointments.forEach((apt, index) => {
console.log(`📋 Consulta ${index + 1}:`, {
id: apt.id,
scheduled_at: apt.scheduled_at,
date: apt.date,
time: apt.time,
doctor_name: apt.doctor_name,
status: apt.status
});
});
// Data de hoje em formato string para comparação
const today = new Date();
const todayString = today.toISOString().split('T')[0]; // YYYY-MM-DD
console.log("<22> Data de hoje (string):", todayString);
// Filtrar consultas futuras (incluindo hoje)
const futureConsultations = appointments.filter(apt => {
// Usar scheduled_at como data principal
const dateField = apt.scheduled_at || apt.date;
if (!dateField) {
console.log("⚠️ Consulta sem data:", apt.id);
return false;
}
// Normalizar a data da consulta
let consultationDate = dateField;
// Se a data contém horário, pegar apenas a parte da data
if (consultationDate.includes('T')) {
consultationDate = consultationDate.split('T')[0];
}
const isFuture = consultationDate >= todayString;
console.log(`📅 Consulta ${apt.id}: ${consultationDate} >= ${todayString} = ${isFuture}`);
return isFuture;
});
console.log("🔮 Consultas futuras encontradas:", futureConsultations.length);
console.log("📋 Lista de consultas futuras:", futureConsultations);
// Mostrar as 2 consultas mais próximas do horário atual (futuras ou passadas)
const consultationsWithProximity = appointments
.map(apt => {
const dateField = apt.scheduled_at || apt.date;
const timeField = apt.time;
if (dateField) {
let consultationDateTime;
if (dateField.includes('T')) {
// Data já inclui horário
consultationDateTime = new Date(dateField);
} else {
// Combinar data com horário
consultationDateTime = new Date(dateField);
if (timeField) {
const [hours, minutes] = timeField.split(':');
consultationDateTime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
} else {
consultationDateTime.setHours(12, 0, 0, 0); // Default meio-dia se não houver horário
}
}
const now = new Date();
const diffInMinutes = Math.abs((consultationDateTime - now) / (1000 * 60));
return { ...apt, proximityScore: diffInMinutes, consultationDateTime };
}
return { ...apt, proximityScore: 999999 }; // Consultas sem data vão para o final
})
.sort((a, b) => a.proximityScore - b.proximityScore)
.slice(0, 2);
console.log("✅ 2 consultas mais próximas da data atual:", consultationsWithProximity);
setNextConsultations(consultationsWithProximity);
};
// FUNÇÃO DE TESTE - FORÇAR EXIBIÇÃO
// Processar exames recentes
const processRecentExams = (reports) => {
console.log("🔬 Processando exames:", reports);
// Ordenar por data de criação (mais recentes primeiro)
const recent = reports
.filter(report => report.created_at) // Apenas com data válida
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 5);
console.log("✅ Exames recentes processados:", recent);
setRecentExams(recent);
};
// Atualizar relógio
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
// Funções auxiliares para status das consultas
const getStatusColor = (status) => {
switch (status) {
case 'confirmed': case 'confirmada': return 'bg-success';
case 'pending': case 'pendente': return 'bg-warning';
case 'cancelled': case 'cancelada': return 'bg-danger';
case 'completed': case 'finalizada': return 'bg-info';
default: return 'bg-primary';
}
};
const getStatusBorderColor = (status) => {
switch (status) {
case 'confirmed': case 'confirmada': return '#28a745';
case 'pending': case 'pendente': return '#ffc107';
case 'cancelled': case 'cancelada': return '#dc3545';
case 'completed': case 'finalizada': return '#17a2b8';
default: return '#007bff';
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'confirmed': case 'confirmada': return 'fa-check';
case 'pending': case 'pendente': return 'fa-clock-o';
case 'cancelled': case 'cancelada': return 'fa-times';
case 'completed': case 'finalizada': return 'fa-check-circle';
default: return 'fa-calendar';
}
};
// Funções auxiliares para status dos exames
const getExamBorderColor = (status) => {
switch (status) {
case 'completed': case 'finalizado': return '#28a745';
case 'draft': case 'rascunho': return '#ffc107';
case 'pending': case 'pendente': return '#17a2b8';
default: return '#6c757d';
}
};
const getExamIconColor = (status) => {
switch (status) {
case 'completed': case 'finalizado': return 'bg-success';
case 'draft': case 'rascunho': return 'bg-warning';
case 'pending': case 'pendente': return 'bg-info';
default: return 'bg-secondary';
}
};
const getExamIcon = (status) => {
switch (status) {
case 'completed': case 'finalizado': return 'fa-check';
case 'draft': case 'rascunho': return 'fa-clock-o';
case 'pending': case 'pendente': return 'fa-file-text';
default: return 'fa-file-o';
}
};
const getExamBadgeClass = (status) => {
switch (status) {
case 'completed': case 'finalizado': return 'bg-success';
case 'draft': case 'rascunho': return 'bg-warning';
case 'pending': case 'pendente': return 'bg-info';
default: return 'bg-secondary';
}
};
const getExamStatusText = (status) => {
switch (status) {
case 'completed': case 'finalizado': return 'Concluído';
case 'draft': case 'rascunho': return 'Em análise';
case 'pending': case 'pendente': return 'Disponível';
default: return 'Processando';
}
};
// Estatísticas calculadas baseadas nos dados reais da API + demonstração (apenas consultas não canceladas)
const nonCancelledAppointments = appointments.filter(apt =>
apt.status !== 'cancelled' &&
apt.status !== 'cancelada' &&
apt.status !== 'canceled'
);
const totalConsultas = nonCancelledAppointments.length > 0 ? nonCancelledAppointments.length : 5;
const consultasRealizadas = nonCancelledAppointments.length > 0
? nonCancelledAppointments.filter(apt => apt.status === 'completed' || apt.status === 'finalizada').length
: 3;
const proximasConsultas = nextConsultations.length;
const examesRealizados = reports.length > 0 ? reports.length : 3;
const [previewUrl, setPreviewUrl] = useState(AvatarForm);
useEffect(() => {
const loadAvatar = async () => {
if (!userId) return;
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
const requestOptions = {
headers: myHeaders,
method: 'GET',
redirect: 'follow'
};
try {
const response = await fetch(`${supabaseUrl}/storage/v1/object/avatars/${userId}/avatar.png`, requestOptions);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setPreviewUrl(imageUrl);
return; // Avatar encontrado
}
} catch (error) {
}
// Se chegou até aqui, não encontrou avatar - mantém o padrão
};
loadAvatar();
}, [userId]);
if (loading) {
return (
<div className="page-wrapper">
<div className="content">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "400px" }}>
<div className="text-center">
<div className="spinner-border text-primary" role="status">
<span className="sr-only">Carregando...</span>
</div>
<p className="text-muted mt-3">Carregando dashboard...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="page-wrapper">
<div className="content">
{/* Header com informações do paciente */}
<div className="page-header">
<div className="row">
<div className="col-sm-12">
<div className="user-info-banner" style={{
background: `linear-gradient(135deg, #004a99, #0077cc), url(${banner})`,
backgroundSize: 'cover',
borderRadius: '15px',
padding: '30px',
color: 'white',
marginBottom: '20px'
}}>
<div className="row align-items-center">
<div className="col-md-8">
<h2 className="mb-2" style={{color: 'white'}}>👋 Olá, {patientName}!</h2>
<p className="mb-2" style={{ color: 'white' }}>Acompanhe suas consultas, resultados e tudo o que precisa em um lugar.
Cuide-se, e deixe o resto com a gente 💙</p>
<small className="opacity-75">
🕒 {currentTime.toLocaleString('pt-BR')}
</small>
</div>
<div className="col-md-4 text-right">
<img
src={previewUrl}
alt="Avatar"
className="rounded-circle"
style={{ width: '80px', height: '80px', objectFit: 'cover', border: '3px solid white' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Cards de estatísticas */}
<div className="row">
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg1">
<i className="fa fa-calendar-check-o" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{totalConsultas}</h3>
<span className="widget-title1">Total Consultas</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg2">
<i className="fa fa-clock-o" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{proximasConsultas}</h3>
<span className="widget-title2">Próximas Consultas</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg3">
<i className="fa fa-stethoscope" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{examesRealizados}</h3>
<span className="widget-title3">Laudos</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg4">
<i className="fa fa-check-circle" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{consultasRealizadas}</h3>
<span className="widget-title4">Consultas Realizadas</span>
</div>
</div>
</div>
</div>
{/* Ações rápidas */}
<div className="row mb-4">
<div className="col-12">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-body">
<div className="row">
<div className="col-md-3 col-sm-6 mb-3">
<Link to="/paciente/medicosdisponiveis" className="btn btn-outline-primary btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-user-md mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Agendar Consulta
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<Link to="/paciente/consultalist" className="btn btn-outline-success btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-calendar mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Minhas Consultas
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<Link to="/paciente/laudolist" className="btn btn-outline-info btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-file-text mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Meus Laudos
</Link>
</div>
<div className="col-md-3 col-sm-6 mb-3">
<button className="btn btn-outline-warning btn-lg w-100" style={{ borderRadius: '10px' }}>
<i className="fa fa-phone mb-2" style={{ fontSize: '24px', display: 'block' }}></i>
Emergência
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="row">
{/* Próximas Consultas */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header d-flex justify-content-between align-items-center">
<h4 className="card-title">📅 Próximas Consultas</h4>
<Link className="btn btn-primary btn-sm" to="/paciente/consultalist" style={{ borderRadius: '8px' }}>
Ver todas
</Link>
</div>
<div className="card-body">
{nextConsultations.length > 0 ? (
<div className="row">
{nextConsultations.map((consultation, index) => (
<div key={consultation.id} className="col-12 mb-2">
<div className="d-flex align-items-center p-2 rounded" style={{
background: '#f8f9fa',
borderRadius: '10px',
color: '#333',
border: '1px solid #dee2e6',
borderLeftWidth: '4px',
borderLeftColor: getStatusBorderColor(consultation.status)
}}>
<div className="consultation-icon me-3">
<div className={`${getStatusColor(consultation.status)} rounded-circle d-flex align-items-center justify-content-center`} style={{ width: '35px', height: '35px' }}>
<i className={`fa ${getStatusIcon(consultation.status)} text-white`}></i>
</div>
</div>
<div className="flex-grow-1">
<h6 className="mb-0">{consultation.doctor_name || 'Médico não informado'}</h6>
<small className="text-muted">
{(() => {
const dateToShow = consultation.scheduled_at || consultation.date;
if (dateToShow) {
// Extrair data e hora sem conversão de fuso horário
let dateStr = '';
let timeStr = '';
if (dateToShow.includes('T')) {
// Formato ISO: 2025-11-15T14:30:00
const [datePart, timePart] = dateToShow.split('T');
const [year, month, day] = datePart.split('-');
dateStr = `${day}/${month}/${year}`;
if (timePart) {
const [hour, minute] = timePart.split(':');
timeStr = `${hour}:${minute}`;
}
} else {
// Formato simples: 2025-11-15
const [year, month, day] = dateToShow.split('-');
dateStr = `${day}/${month}/${year}`;
}
// Usar horário do campo time se existir, senão usar o extraído
const finalTime = consultation.time || timeStr || 'Horário a confirmar';
return `${dateStr} às ${finalTime}`;
}
return 'Data a confirmar';
})()}
</small>
</div>
<span
className={`custom-badge ${
consultation.status === 'requested' ? 'status-orange' :
consultation.status === 'confirmed' ? 'status-blue' :
consultation.status === 'completed' ? 'status-green' :
consultation.status === 'cancelled' ? 'status-red' :
'status-gray'
}`}
style={{ minWidth: '110px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
{consultation.status === 'requested' ? (
<>
<i className="fa fa-clock-o" style={{ marginRight: '6px' }}></i>
Solicitado
</>
) : consultation.status === 'confirmed' ? (
<>
<i className="fa fa-check-circle" style={{ marginRight: '6px' }}></i>
Confirmado
</>
) : consultation.status === 'completed' ? (
<>
<i className="fa fa-check" style={{ marginRight: '6px' }}></i>
Concluído
</>
) : consultation.status === 'cancelled' ? (
<>
<i className="fa fa-times-circle" style={{ marginRight: '6px' }}></i>
Cancelado
</>
) : (
<>
<i className="fa fa-question-circle" style={{ marginRight: '6px' }}></i>
{consultation.status}
</>
)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted py-4">
<i className="fa fa-calendar-o fa-3x mb-3"></i>
<p>Nenhuma consulta agendada</p>
<Link to="/paciente/medicosdisponiveis" className="btn btn-primary btn-sm">
Agendar primeira consulta
</Link>
</div>
)}
{/* Botão para ver mais */}
<div className="text-center mt-3">
<Link to="/paciente/medicosdisponiveis" className="btn btn-outline-primary btn-sm">
<i className="fa fa-plus me-2"></i>
Agendar nova consulta
</Link>
</div>
</div>
</div>
</div>
{/* Exames Recentes */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header d-flex justify-content-between align-items-center">
<h4 className="card-title">🔬 Laudos Recentes</h4>
<Link className="btn btn-primary btn-sm" to="/paciente/laudolist" style={{ borderRadius: '8px' }}>
Ver todos
</Link>
</div>
<div className="card-body">
{recentExams.length > 0 ? (
<div className="row">
{recentExams.map((exam) => (
<div key={exam.id} className="col-12 mb-2">
<div className="d-flex align-items-center p-2 rounded" style={{
background: '#f8f9fa',
borderRadius: '10px',
color: '#333',
border: '1px solid #dee2e6',
borderLeftWidth: '4px',
borderLeftColor: getExamBorderColor(exam.status)
}}>
<div className="exam-icon me-3">
<div className={`${getExamIconColor(exam.status)} rounded-circle d-flex align-items-center justify-content-center`} style={{ width: '35px', height: '35px' }}>
<i className={`fa ${getExamIcon(exam.status)} text-white`}></i>
</div>
</div>
<div className="flex-grow-1">
<h6 className="mb-0">{exam.exam || 'Exame'}</h6>
<small className="text-muted">
{new Date(exam.created_at).toLocaleDateString('pt-BR')} - {exam.requested_by || 'Médico não informado'}
</small>
</div>
<span
className={`custom-badge ${
exam.status === 'draft' ? 'status-orange' :
exam.status === 'completed' ? 'status-green' :
'status-gray'
}`}
style={{ minWidth: '110px', display: 'inline-block', textAlign: 'center' }}
>
{exam.status === 'draft' ? (
<>
<i className="fa fa-edit" style={{ marginRight: '6px' }}></i>
Rascunho
</>
) : exam.status === 'completed' ? (
<>
<i className="fa fa-check-circle" style={{ marginRight: '6px' }}></i>
Concluído
</>
) : (
exam.status
)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted py-4">
<i className="fa fa-file-text-o fa-3x mb-3"></i>
<p>Nenhum laudo realizado ainda</p>
</div>
)}
<div className="text-center mt-3">
<Link to="/paciente/laudolist" className="btn btn-outline-info btn-sm">
<i className="fa fa-eye me-2"></i>
Ver todos os laudos
</Link>
</div>
</div>
</div>
</div>
</div>
{/* Informações de saúde */}
<div className="row">
<div className="col-12">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">💡 Dicas de Saúde</h4>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-4 text-center mb-3">
<div className="health-tip p-3" style={{ backgroundColor: '#f8f9fa', borderRadius: '10px' }}>
<i className="fa fa-heart text-danger fa-2x mb-2"></i>
<h6>Exercite-se Regularmente</h6>
<p className="text-muted small">30 minutos de atividade física por dia fazem a diferença</p>
</div>
</div>
<div className="col-md-4 text-center mb-3">
<div className="health-tip p-3" style={{ backgroundColor: '#f8f9fa', borderRadius: '10px' }}>
<i className="fa fa-coffee text-success fa-2x mb-2"></i>
<h6>Alimentação Saudável</h6>
<p className="text-muted small">Consuma frutas e vegetais todos os dias</p>
</div>
</div>
<div className="col-md-4 text-center mb-3">
<div className="health-tip p-3" style={{ backgroundColor: '#f8f9fa', borderRadius: '10px' }}>
<i className="fa fa-bed text-primary fa-2x mb-2"></i>
<h6>Durma Bem</h6>
<p className="text-muted small">7-8 horas de sono são essenciais para sua saúde</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// CSS customizado para o PatientDashboard
const style = document.createElement('style');
style.textContent = `
.timeline {
position: relative;
}
.timeline-item {
position: relative;
}
.timeline-marker {
width: 12px;
height: 12px;
border-radius: 50%;
margin-top: 6px;
flex-shrink: 0;
}
.timeline-content {
background: #f8f9fa;
padding: 15px;
border-radius: 10px;
border-left: 3px solid #007bff;
width: 100%;
}
.exam-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.health-tip {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.health-tip:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.dash-widget {
transition: transform 0.2s ease;
}
.dash-widget:hover {
transform: translateY(-3px);
}
.user-info-banner {
position: relative;
overflow: hidden;
}
.user-info-banner::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="0%" r="100%"><stop offset="0%" style="stop-color:rgba(255,255,255,0.1)"/><stop offset="100%" style="stop-color:rgba(255,255,255,0)"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
pointer-events: none;
}
`;
if (!document.head.querySelector('[data-patient-dashboard-styles]')) {
style.setAttribute('data-patient-dashboard-styles', 'true');
document.head.appendChild(style);
}

View File

@ -1,242 +0,0 @@
import "../../assets/css/index.css"
function AddSchedule(){
return (
<div className="main-wrapper">
<div className="header">
<a id="toggle_btn" href="#">
<i className="fa fa-bars"></i>
</a>
<a id="mobile_btn" className="mobile_btn float-left" href="#sidebar">
<i className="fa fa-bars"></i>
</a>
<ul className="nav user-menu float-right">
<li className="nav-item dropdown d-none d-sm-block">
<a
href="#"
className="dropdown-toggle nav-link"
data-toggle="dropdown"
>
<i className="fa fa-bell-o"></i>{" "}
<span className="badge badge-pill bg-danger float-right">3</span>
</a>
<div className="dropdown-menu notifications">
<div className="topnav-dropdown-header">
<span>Notifications</span>
</div>
<div className="drop-scroll">
<ul className="notification-list">
<li className="notification-message">
<a href="activities.html">
<div className="media">
<span className="avatar">
<img
alt="John Doe"
src="assets/img/user.jpg"
className="img-fluid rounded-circle"
/>
</span>
<div className="media-body">
<p className="noti-details">
<span className="noti-title">John Doe</span> added
new task{" "}
<span className="noti-title">
Patient appointment booking
</span>
</p>
<p className="noti-time">
<span className="notification-time">4 mins ago</span>
</p>
</div>
</div>
</a>
</li>
{/* ... outras notificações */}
</ul>
</div>
<div className="topnav-dropdown-footer">
<a href="activities.html">View all Notifications</a>
</div>
</div>
</li>
<li className="nav-item dropdown has-arrow">
<a
href="#"
className="dropdown-toggle nav-link user-link"
data-toggle="dropdown"
>
<span className="user-img">
<img
className="rounded-circle"
src="assets/img/user.jpg"
width="40"
alt="Admin"
/>
<span className="status online"></span>
</span>
<span>Admin</span>
</a>
<div className="dropdown-menu">
<a className="dropdown-item" href="profile.html">
My Profile
</a>
<a className="dropdown-item" href="edit-profile.html">
Edit Profile
</a>
<a className="dropdown-item" href="settings.html">
Settings
</a>
<a className="dropdown-item" href="login.html">
Logout
</a>
</div>
</li>
</ul>
<div className="dropdown mobile-user-menu float-right">
<a
href="#"
className="nav-link dropdown-toggle"
data-toggle="dropdown"
aria-expanded="false"
>
<i className="fa fa-ellipsis-v"></i>
</a>
<div className="dropdown-menu dropdown-menu-right">
<a className="dropdown-item" href="profile.html">
My Profile
</a>
<a className="dropdown-item" href="edit-profile.html">
Edit Profile
</a>
<a className="dropdown-item" href="settings.html">
Settings
</a>
<a className="dropdown-item" href="login.html">
Logout
</a>
</div>
</div>
</div>
{/* Conteúdo da página */}
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-lg-8 offset-lg-2">
<h4 className="page-title">Adicionar Agenda</h4>
</div>
</div>
<div className="row">
<div className="col-lg-8 offset-lg-2">
<form>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Nome</label>
<select className="select form-control">
<option>Selecionar</option>
<option>Doctor Name 1</option>
<option>Doctor Name 2</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Dias disponíveis</label>
<select className="form-control">
<option>Selecione</option>
<option>Segunda-feira</option>
<option>Terça-feira</option>
<option>Quarta-feira</option>
<option>Quinta-feira</option>
<option>Sexta-feira</option>
<option>Sábado</option>
</select>
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="form-group">
<label>Hora</label>
<input
type="time"
className="form-control"
id="datetimepicker3"
/>
</div>
</div>
<div className="col-md-6">
<div className="form-group">
<label>Fim</label>
<input
type="time"
className="form-control"
id="datetime"
/>
</div>
</div>
</div>
<div className="form-group">
<label>Mensagem</label>
<textarea
cols="30"
rows="4"
className="form-control"
></textarea>
</div>
<div className="form-group">
<label className="display-block">Status</label>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_active"
value="option1"
defaultChecked
/>
<label className="form-check-label" htmlFor="product_active">
Ativo
</label>
</div>
<div className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
name="status"
id="product_inactive"
value="option2"
/>
<label
className="form-check-label"
htmlFor="product_inactive"
>
Inativo
</label>
</div>
</div>
<div className="m-t-20 text-center">
<button className="btn btn-primary submit-btn">
Criar agenda
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div className="sidebar-overlay" data-reff=""></div>
</div>
);
};
export default AddSchedule;

View File

@ -1,107 +0,0 @@
import "../../assets/css/index.css"
import { Link } from "react-router-dom";
function Doctorschedule() {
return (
<div className="main-wrapper">
<div className="page-wrapper">
<div className="content">
<div className="row">
<div className="col-sm-4 col-3">
<h4 className="page-title">Agenda médica</h4>
</div>
<div className="col-sm-8 col-9 text-right m-b-20">
<Link
to ="/addschedule"
className="btn btn-primary btn-rounded float-right"
>
<i className="fa fa-plus"></i> Adicionar agenda
</Link>
</div>
</div>
<div className="row">
<div className="col-md-12">
<div className="table-responsive">
<table className="table table-border table-striped custom-table mb-0">
<thead>
<tr>
<th>Nome</th>
<th>Departamento</th>
<th>Dias disponíveis</th>
<th>Horário disponível</th>
<th>Status</th>
<th className="text-right">Ação</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<img
width="28"
height="28"
src="/img/user.jpg"
className="rounded-circle m-r-5"
alt="user"
/>{" "}
Henry Daniels
</td>
<td>Cardiologista</td>
<td>Segunda-feira, Terça-feira, Quinta-feira</td>
<td>10:00 AM - 7:00 PM</td>
<td>
<span className="custom-badge status-green">Ativo</span>
</td>
<td className="text-right">
<div className="dropdown dropdown-action">
<a
href="#"
className="action-icon dropdown-toggle"
>
<i className="fa fa-ellipsis-v"></i>
</a>
<div className="dropdown-menu dropdown-menu-right">
<a className="dropdown-item" href="edit-schedule.html">
<i className="fa fa-pencil m-r-5"></i> Editar
</a>
<a
className="dropdown-item"
href="#"
>
<i className="fa fa-trash-o m-r-5"></i> Deletar
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/* Modal de exclusão */}
<div id="delete_schedule" className="modal fade delete-modal" role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-body text-center">
<img src="assets/img/sent.png" alt="" width="50" height="46" />
<h3>Você tem certeza que deseja deletar essa agenda?</h3>
<div className="m-t-20">
<a href="#" className="btn btn-white">
Fechar
</a>
<button type="submit" className="btn btn-danger">
Deletar
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Doctorschedule

View File

@ -0,0 +1,93 @@
import { Outlet, NavLink, useLocation } from "react-router-dom";
import './../../assets/css/index.css'
import Navbar from '../../components/layouts/Navbar'
import React, { useState } from 'react';
import { Link } from "react-router-dom";
import Chatbox from '../../components/chat/Chatbox';
import AccessibilityWidget from '../../components/AccessibilityWidget';
import { useResponsive } from '../../utils/useResponsive';
import { getAccessToken } from '../../utils/auth.js';
import { getUserRole } from '../../utils/userInfo.js';
import { Navigate } from 'react-router-dom';
import Sidebar from "../../components/layouts/Sidebar.jsx";
function SecretariaApp() {
// 1. Adicione o estado para controlar a sidebar
const [isSidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// 2. Adicione a função para alternar o estado
const toggleSidebar = () => {
setSidebarOpen(!isSidebarOpen);
};
// 3. Crie a string de classe que será aplicada dinamicamente
const mainWrapperClass = isSidebarOpen ? 'main-wrapper sidebar-open' : 'main-wrapper';
// Função para verificar se a rota está ativa
const isActive = (path) => {
const currentPath = location.pathname;
// Verificação exata primeiro
if (currentPath === path) return true;
// Verificação de subrotas (ex: /secretaria/pacientelista/edit/123)
if (currentPath.startsWith(path + '/')) return true;
// Verificações específicas para páginas de edição/criação
if (path === '/secretaria/pacientelista' && (
currentPath.includes('/secretaria/pacienteeditar/') ||
currentPath.includes('/secretaria/pacienteform')
)) return true;
if (path === '/secretaria/medicoslista' && (
currentPath.includes('/secretaria/medicoseditar/')
)) return true;
if (path === '/secretaria/secretariaconsultalist' && (
currentPath.includes('/secretaria/editarconsulta/') ||
currentPath.includes('/secretaria/adicionarconsulta') ||
currentPath.includes('/secretaria/consulta/')
)) return true;
if (path === '/secretaria/agendamedica' && (
currentPath.includes('/secretaria/adicionaragenda')
)) return true;
return false;
};
const token = getAccessToken();
const user = getUserRole();
// Verificação de autenticação
if (!token) {
return <Navigate to="/login" replace />;
}
// Verificação de role
if (user !== 'secretaria') {
return (
<div className="page-wrapper">
<div className="content">
<div className="alert alert-danger text-center">
<h4> Acesso Negado</h4>
<p>Apenas secretárias podem acessar esta área.</p>
<button
className="btn btn-primary"
onClick={() => window.history.back()}
>
Voltar
</button>
</div>
</div>
</div>
);
}
return (
<div>
<Sidebar />
<Outlet />
</div>
);
}
export default SecretariaApp;

View File

@ -0,0 +1,845 @@
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { getAccessToken } from "../../utils/auth.js";
import "../../assets/css/index.css";
import { getFullName, getUserId } from "../../utils/userInfo";
const AvatarForm = "/img/AvatarForm.jpg";
const banner = "/img/banner.png";
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend as ChartLegend,
} from 'chart.js';
import { Bar as ChartJSBar } from 'react-chartjs-2';
// Registrar componentes do Chart.js
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
ChartTooltip,
ChartLegend
);
// Componente do gráfico de consultas mensais
const ConsultasMensaisChart = ({ data }) => (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mes" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px'
}}
formatter={(value) => [`${value} consultas`, 'Total']}
/>
<Legend />
<Bar dataKey="consultas" fill="#007bff" name="Consultas" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
// Componente do gráfico de pacientes ativos/inativos
const AtivosInativosChart = ({ data }) => (
<ResponsiveContainer width="100%" height={350}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={120}
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px'
}}
formatter={(value, name) => [`${value} pacientes`, name]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
);
// Componente do gráfico de consultas por médico com Chart.js (horizontal)
const ConsultasPorMedicoChart = ({ data }) => {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado de médicos encontrado</p>
</div>
</div>
);
}
const chartData = {
labels: data.map(item => item.medico),
datasets: [
{
label: 'Consultas',
data: data.map(item => item.consultas),
backgroundColor: '#28a745',
borderColor: '#1e7e34',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
}
]
};
const options = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#f8f9fa',
titleColor: '#343a40',
bodyColor: '#343a40',
borderColor: '#dee2e6',
borderWidth: 1,
callbacks: {
label: function (context) {
return `${context.parsed.x} consultas`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
color: '#e9ecef',
drawBorder: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
}
}
},
y: {
grid: {
display: false,
},
ticks: {
color: '#6c757d',
font: {
size: 11
},
maxRotation: 0,
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
layout: {
padding: {
left: 20,
right: 30,
top: 10,
bottom: 10
}
},
elements: {
bar: {
borderRadius: 4,
}
}
};
return (
<div style={{ width: '100%', height: '350px', backgroundColor: '#ffffff' }}>
<ChartJSBar data={chartData} options={options} />
</div>
);
};
// Componente do gráfico de taxa de cancelamentos
const TaxaCancelamentosChart = ({ data }) => {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado de cancelamentos encontrado</p>
</div>
</div>
);
}
// Preparar dados para Chart.js (gráfico empilhado)
const chartData = {
labels: data.map(item => item.mes),
datasets: [
{
label: 'Realizadas',
data: data.map(item => item.realizadas),
backgroundColor: '#dee2e6',
borderColor: '#adb5bd',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
},
{
label: 'Canceladas',
data: data.map(item => item.canceladas),
backgroundColor: '#dc3545',
borderColor: '#c82333',
borderWidth: 1,
borderRadius: 4,
borderSkipped: false,
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
}
}
},
y: {
stacked: true,
beginAtZero: true,
max: 100,
grid: {
color: '#e9ecef',
drawBorder: false,
},
ticks: {
color: '#6c757d',
font: {
size: 12
},
callback: function (value) {
return value + '%';
}
}
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#495057',
font: {
size: 12
},
usePointStyle: true,
pointStyle: 'rect'
}
},
tooltip: {
backgroundColor: '#f8f9fa',
titleColor: '#343a40',
bodyColor: '#343a40',
borderColor: '#dee2e6',
borderWidth: 1,
callbacks: {
label: function (context) {
const datasetLabel = context.dataset.label;
const value = context.parsed.y;
const dataIndex = context.dataIndex;
const monthData = data[dataIndex];
if (datasetLabel === 'Canceladas') {
const numConsultas = Math.round(monthData.total * value / 100);
return `${datasetLabel}: ${value}% (${numConsultas} de ${monthData.total} consultas)`;
} else {
const numConsultas = Math.round(monthData.total * value / 100);
return `${datasetLabel}: ${value}% (${numConsultas} consultas)`;
}
},
title: function (context) {
const monthData = data[context[0].dataIndex];
return `${context[0].label} ${new Date().getFullYear()} - Total: ${monthData.total} consultas`;
},
afterBody: function (context) {
const monthData = data[context[0].dataIndex];
if (monthData.total === 0) {
return ['Nenhuma consulta registrada neste mês'];
}
return [];
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
layout: {
padding: {
left: 10,
right: 10,
top: 10,
bottom: 10
}
}
};
return (
<div style={{ width: '100%', height: '350px', backgroundColor: '#ffffff' }}>
<ChartJSBar data={chartData} options={options} />
</div>
);
};
function SecretariaDashboard() {
const [patients, setPatients] = useState([]);
const [doctors, setDoctors] = useState([]);
const [consulta, setConsulta] = useState([]);
const [countPaciente, setCountPaciente] = useState(0);
const [countMedico, setCountMedico] = useState(0);
// Estados para os gráficos
const [consultasMensaisDataReal, setConsultasMensaisDataReal] = useState([]);
const [pacientesStatusDataReal, setPacientesStatusDataReal] = useState([]);
const [consultasPorMedicoData, setConsultasPorMedicoData] = useState([]);
const [taxaCancelamentosData, setTaxaCancelamentosData] = useState([]);
const [appointments, setAppointments] = useState([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [previewUrl, setPreviewUrl] = useState(AvatarForm);
const tokenUsuario = getAccessToken();
const userId = getUserId();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAK = import.meta.env.VITE_SUPABASE_ANON_KEY;
const requestOptions = {
method: "GET",
headers: {
apikey:
supabaseAK,
Authorization: `Bearer ${tokenUsuario}`,
},
redirect: "follow",
};
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
// Buscar pacientes
const patientsResponse = await fetch(
`${supabaseUrl}/rest/v1/patients`,
requestOptions
);
const patientsData = await patientsResponse.json();
const patientsArr = Array.isArray(patientsData) ? patientsData : [];
setPatients(patientsArr);
setConsulta(patientsArr);
setCountPaciente(patientsArr.length);
// Processar status dos pacientes
if (patientsArr.length > 0) {
const ativos = patientsArr.filter(p => p.active !== false).length;
const inativos = patientsArr.length - ativos;
const statusData = [
{ name: 'Ativos', value: ativos, color: '#007bff' },
{ name: 'Inativos', value: inativos, color: '#ffa500' }
];
setPacientesStatusDataReal(statusData);
}
// Buscar médicos
const doctorsResponse = await fetch(
`${supabaseUrl}/rest/v1/doctors`,
requestOptions
);
const doctorsData = await doctorsResponse.json();
const doctorsArr = Array.isArray(doctorsData) ? doctorsData : [];
setDoctors(doctorsArr);
setCountMedico(doctorsArr.length);
// Buscar consultas
const appointmentsResponse = await fetch(
`${supabaseUrl}/rest/v1/appointments`,
requestOptions
);
const appointmentsData = await appointmentsResponse.json();
const appointmentsArr = Array.isArray(appointmentsData) ? appointmentsData : [];
setAppointments(appointmentsArr);
// Processar dados dos gráficos
processConsultasMensais(appointmentsArr);
await processConsultasPorMedico(appointmentsArr, doctorsArr);
processTaxaCancelamentos(appointmentsArr);
} catch (error) {
} finally {
setLoading(false);
}
};
loadData();
}, []);
// useEffect para atualizar o relógio em tempo real
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
// useEffect para carregar avatar do usuário (mesma lógica da navbar)
useEffect(() => {
const loadAvatar = async () => {
if (!userId) return;
const myHeaders = new Headers();
myHeaders.append("apikey", supabaseAK);
myHeaders.append("Authorization", `Bearer ${tokenUsuario}`);
const requestOptions = {
headers: myHeaders,
method: 'GET',
redirect: 'follow'
};
try {
const response = await fetch(`${supabaseUrl}/storage/v1/object/avatars/${userId}/avatar.png`, requestOptions);
if (response.ok) {
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setPreviewUrl(imageUrl);
return;
}
} catch (error) {
}
};
loadAvatar();
}, [userId]);
// Processar dados das consultas mensais
const processConsultasMensais = (appointmentsData) => {
const meses = [
'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'
];
const consultasPorMes = meses.map(mes => ({ mes, consultas: 0 }));
if (appointmentsData && appointmentsData.length > 0) {
appointmentsData.forEach(appointment => {
if (appointment.scheduled_at) {
const data = new Date(appointment.scheduled_at);
const mesIndex = data.getMonth();
if (mesIndex >= 0 && mesIndex < 12) {
consultasPorMes[mesIndex].consultas++;
}
}
});
}
setConsultasMensaisDataReal(consultasPorMes);
};
// Processar dados das consultas por médico
const processConsultasPorMedico = async (appointmentsData, doctorsData) => {
try {
// Criar mapa de médicos
const doctorsMap = {};
doctorsData.forEach(doctor => {
let doctorName = doctor.full_name || doctor.name || `Médico ${doctor.id}`;
doctorName = doctorName.trim();
doctorsMap[doctor.id] = doctorName;
});
// Contar consultas por médico
const consultasPorMedico = {};
appointmentsData.forEach(appointment => {
if (appointment.doctor_id) {
const doctorName = doctorsMap[appointment.doctor_id] || `Médico ${appointment.doctor_id}`;
consultasPorMedico[doctorName] = (consultasPorMedico[doctorName] || 0) + 1;
}
});
// Converter para array e ordenar por número de consultas (maior para menor)
const chartData = Object.entries(consultasPorMedico)
.map(([medico, consultas]) => ({ medico, consultas }))
.sort((a, b) => b.consultas - a.consultas)
.slice(0, 10); // Mostrar apenas os top 10 médicos
setConsultasPorMedicoData(chartData);
} catch (error) {
setConsultasPorMedicoData([]);
}
};
// Processar dados da taxa de cancelamentos
const processTaxaCancelamentos = (appointmentsData) => {
const meses = [
'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun',
'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'
];
const cancelamentosPorMes = meses.map(mes => ({
mes,
realizadas: 0,
canceladas: 0,
total: 0
}));
if (appointmentsData && appointmentsData.length > 0) {
appointmentsData.forEach(appointment => {
if (appointment.scheduled_at) {
const data = new Date(appointment.scheduled_at);
const mesIndex = data.getMonth();
const anoAtual = new Date().getFullYear();
const anoConsulta = data.getFullYear();
// Processar apenas consultas do ano atual
if (mesIndex >= 0 && mesIndex < 12 && anoConsulta === anoAtual) {
cancelamentosPorMes[mesIndex].total++;
// Verificar diferentes possíveis campos de status de cancelamento
const isCancelled =
appointment.status === 'cancelled' ||
appointment.status === 'canceled' ||
appointment.cancelled === true ||
appointment.is_cancelled === true ||
appointment.appointment_status === 'cancelled' ||
appointment.appointment_status === 'canceled';
if (isCancelled) {
cancelamentosPorMes[mesIndex].canceladas++;
} else {
cancelamentosPorMes[mesIndex].realizadas++;
}
}
}
});
// Calcular porcentagens e manter valores absolutos para tooltip
cancelamentosPorMes.forEach(mes => {
if (mes.total > 0) {
const realizadasCount = mes.realizadas;
const canceladasCount = mes.canceladas;
mes.realizadas = Math.round((realizadasCount / mes.total) * 100);
mes.canceladas = Math.round((canceladasCount / mes.total) * 100);
// Garantir que soma seja 100%
if (mes.realizadas + mes.canceladas !== 100 && mes.total > 0) {
mes.realizadas = 100 - mes.canceladas;
}
} else {
// Se não há dados, mostrar 100% realizadas
mes.realizadas = 100;
mes.canceladas = 0;
}
});
setTaxaCancelamentosData(cancelamentosPorMes);
} else {
// Se não há dados da API, deixar vazio
setTaxaCancelamentosData([]);
}
};
return (
<div className="page-wrapper">
<div className="content">
{/* Header com informações da secretária */}
<div className="page-header">
<div className="row">
<div className="col-sm-12">
<div className="user-info-banner" style={{
background: `linear-gradient(135deg, #004a99, #0077cc), url(${banner})`,
backgroundSize: 'cover',
borderRadius: '15px',
padding: '30px',
color: 'white',
marginBottom: '20px'
}}>
<div className="row align-items-center">
<div className="col-md-8">
<h2 className="mb-2" style={{color:"white"}}>📋 Olá, {getFullName()}!</h2>
<p className="mb-2" style={{ color: 'white' }}>O MediConnect está pronto para mais um dia de organização e cuidado. Continue ajudando nossa clínica a funcionar de forma leve, eficiente e acolhedora!</p>
<small className="opacity-75">
🕒 {currentTime.toLocaleString('pt-BR')}
</small>
</div>
<div className="col-md-4 text-right">
<img
src={previewUrl}
alt="Avatar"
className="rounded-circle"
style={{ width: '80px', height: '80px', objectFit: 'cover', border: '3px solid white' }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Cards de estatísticas */}
<div className="row">
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg2">
<i className="fa fa-users" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{countPaciente}</h3>
<span className="widget-title2">Total Pacientes</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg1">
<i className="fa fa-user-md" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{countMedico}</h3>
<span className="widget-title1">Total Médicos</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg3">
<i className="fa fa-stethoscope" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>{appointments.length}</h3>
<span className="widget-title3">Total Consultas</span>
</div>
</div>
</div>
<div className="col-md-6 col-sm-6 col-lg-6 col-xl-3">
<div className="dash-widget" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<span className="dash-widget-bg4">
<i className="fa fa-heartbeat" aria-hidden="true"></i>
</span>
<div className="dash-widget-info text-right">
<h3>80</h3>
<span className="widget-title4">Atendidos</span>
</div>
</div>
</div>
</div>
{/* Seção dos Gráficos */}
<div className="row">
{/* Consultas por Mês */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">📊 Consultas por Mês ({new Date().getFullYear()})</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : consultasMensaisDataReal.length > 0 ? (
<ConsultasMensaisChart data={consultasMensaisDataReal} />
) : (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-chart-bar fa-2x mb-2"></i>
<p>Nenhum dado encontrado</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Top 10 Médicos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">🏆 Top 10 Médicos (Consultas)</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : (
<ConsultasPorMedicoChart data={consultasPorMedicoData} />
)}
</div>
</div>
</div>
</div>
<div className="row">
{/* Pacientes Ativos/Inativos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">👥 Pacientes Ativos x Inativos</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : pacientesStatusDataReal.length > 0 ? (
<AtivosInativosChart data={pacientesStatusDataReal} />
) : (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-pie-chart fa-2x mb-2"></i>
<p>Nenhum dado encontrado</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Taxa de Cancelamentos */}
<div className="col-12 col-lg-6 mb-4">
<div className="card" style={{ borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}>
<div className="card-header">
<h4 className="card-title">📉 Taxa de Cancelamentos</h4>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted" style={{ height: '350px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>
<i className="fa fa-spinner fa-spin fa-2x mb-2"></i>
<p>Carregando dados...</p>
</div>
</div>
) : (
<TaxaCancelamentosChart data={taxaCancelamentosData} />
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// CSS customizado para o SecretariaDashboard (mesmo estilo do AdminDashboard)
const style = document.createElement('style');
style.textContent = `
.user-info-banner {
position: relative;
overflow: hidden;
}
.user-info-banner::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="0%" r="100%"><stop offset="0%" style="stop-color:rgba(255,255,255,0.1)"/><stop offset="100%" style="stop-color:rgba(255,255,255,0)"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
pointer-events: none;
}
.dash-widget {
transition: transform 0.2s ease;
}
.dash-widget:hover {
transform: translateY(-3px);
}
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
}
`;
if (!document.head.querySelector('[data-secretaria-dashboard-styles]')) {
style.setAttribute('data-secretaria-dashboard-styles', 'true');
document.head.appendChild(style);
}
export default SecretariaDashboard;

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