chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# GUIA DE DEPLOY - SCOREODONTO CRM (VPS)
|
||||
|
||||
Este guia detalha o processo de implantação do ScoreOdonto CRM em um servidor VPS (Ubuntu/Debian recomendado).
|
||||
|
||||
## 1. Pré-requisitos
|
||||
* Node.js (v18+) e NPM.
|
||||
* MySQL Server.
|
||||
* Nginx (como Proxy Reverso).
|
||||
* PM2 (Gerenciador de Processos).
|
||||
|
||||
## 2. Configuração do Banco de Dados
|
||||
No servidor MySQL da VPS, crie o banco e as tabelas:
|
||||
```sql
|
||||
CREATE DATABASE sis_odonto CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
Importe seus dados usando o `SyncView` ou o script de atualização após o deploy.
|
||||
|
||||
## 3. Preparação do Ambiente
|
||||
Clone o repositório na VPS e instale as dependências:
|
||||
```bash
|
||||
cd /var/www/scoreodonto
|
||||
npm install
|
||||
```
|
||||
|
||||
Crie o arquivo `.env` na pasta raiz (`scoreodonto/`):
|
||||
```env
|
||||
PORT=3002
|
||||
DB_HOST=localhost
|
||||
DB_USER=seu_usuario
|
||||
DB_PASSWORD=sua_senha
|
||||
DB_NAME=sis_odonto
|
||||
```
|
||||
|
||||
## 4. Build do Frontend
|
||||
Gere os arquivos estáticos para produção:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Isso criará a pasta `dist` (ou `build`).
|
||||
|
||||
## 5. Gerenciamento com PM2
|
||||
Inicie o servidor backend:
|
||||
```bash
|
||||
pm2 start server.js --name "scoreodonto-api"
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 6. Configuração do Nginx
|
||||
Edite o arquivo de configuração do site no Nginx (`/etc/nginx/sites-available/scoreodonto`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
root /var/www/scoreodonto/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
Ative o site e reinicie o Nginx:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/scoreodonto /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
## 7. Ajustes de Produção
|
||||
* **API URL**: O frontend agora usa o `API_URL` relativo ou configurado via environment. Como o Nginx está redirecionando `/api` para o backend, o sistema funcionará corretamente de forma transparente.
|
||||
* **Notificações**: Estão unificadas no topo de cada página para fácil acesso.
|
||||
* **Segurança**: Certifique-se de configurar um certificado SSL (Certbot/Let's Encrypt).
|
||||
|
||||
---
|
||||
*Gerado automaticamente pelo Antigravity.*
|
||||
@@ -0,0 +1,80 @@
|
||||
# 📒 Guia Estratégico e Arquitetura da Automação
|
||||
|
||||
Este documento detalha as escolhas técnicas e o funcionamento estratégico da automação desenvolvida para o **Portal Viventeris (Cassems)**, explicando "o porquê" de cada decisão.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 1. Arquitetura: Por que Playwright e não Axios?
|
||||
|
||||
Ao desenvolver automações para portais de saúde (TISS/SADT), escolhemos o **Playwright** em vez de ferramentas de requisição direta como o **Axios**. Aqui está a justificativa:
|
||||
|
||||
### Playwright (Navegador Real) - *Nossa Escolha*
|
||||
- **Simulação Humana**: Ele carrega o JavaScript do portal, processa pop-ups e lida com o "autocomplete" do jQuery UI (essencial para que o sistema valide o procedimento).
|
||||
- **Gestão de Sessão**: O portal Viventeris possui tokens de segurança e cookies expiráveis. O navegador gerencia isso automaticamente.
|
||||
- **Resiliência Visual**: Se o portal mudar a posição de um campo, a automação baseada em `id` ou `label` continua funcionando.
|
||||
- **Monitoramento**: Você pode ver o navegador trabalhando, o que facilita identificar se o sistema da Cassems está lento ou fora do ar.
|
||||
|
||||
### Axios/API (Requisições Ocultas) - *Descartado*
|
||||
- **Extrema Complexidade**: Exigiria descobrir todos os "requests" ocultos que o servidor espera.
|
||||
- **Fragilidade**: Qualquer mudança na segurança do servidor quebraria a integração sem deixar rastro visual.
|
||||
- **Bloqueios**: Sistemas modernos de saúde detectam requisições "secas" (sem navegador) e bloqueiam o acesso por suspeita de ataque.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 2. Inteligência do Fluxo (Regras de Negócio)
|
||||
|
||||
O script foi treinado para seguir regras específicas de UX do portal:
|
||||
|
||||
1. **Regra de Urgência**:
|
||||
- O sistema monitora o select `#tipoAtendimento`.
|
||||
- Se selecionado **Urgência (ID 4)**, ele obrigatoriamente preenche a `Justificativa`.
|
||||
- Se selecionado **Eletivo (ID 1)**, ele pula o campo para evitar erros de validação.
|
||||
2. **Lógica do Autocomplete**:
|
||||
- Digitar o código do procedimento não é suficiente. O script foi programado para **clicar no item da lista suspensa** que o sistema sugere. Sem esse clique, o sistema muitas vezes "limpa" o campo ao tentar avançar.
|
||||
3. **Superação de Sobreposição**:
|
||||
- No campo de **Faces (V, M, D, L/P)**, o script utiliza cliques forçados nas labels, pois os checkboxes originais são ocultados por CSS de design do portal.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 3. Manutenção e Boas Práticas
|
||||
|
||||
### Como manter o código funcionando:
|
||||
- **IDs Estáveis**: Sempre que possível, usamos o `id` (ex: `#cmbDente`). Se o portal mudar o layout, mas mantiver os IDs, o código **não quebra**.
|
||||
- **Timeouts**: Configuramos esperas inteligentes. O script não trava se a internet oscilar por 2 segundos; ele aguarda o elemento aparecer.
|
||||
|
||||
### Segurança dos Dados:
|
||||
- **Credenciais**: Atualmente fixas no código para testes. Em produção, recomenda-se usar variáveis de ambiente ou um arquivo de configuração externo.
|
||||
- **Breakpoint de Erro**: O código possui a função `catch`. Se algo der errado (ex: beneficiário bloqueado), o script **para tudo**, tira um print da tela (`erro-guia-odonto.png`) e mantém o navegador aberto para você ver o motivo.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. Infraestrutura Atual (VM Windows)
|
||||
|
||||
Como estamos operando dentro de uma Máquina Virtual, optamos pela instalação nativa para garantir performance e estabilidade:
|
||||
|
||||
| Recurso | Status | Host:Porta | Credenciais | Gerenciamento |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **MySQL (9.6)** | Ativo (Serviço) | `localhost:3306` | User: `root` / Pass: (vazio) | MySQL CLI / DBeaver |
|
||||
| **Redis (8.6)** | Ativo (PM2) | `localhost:6379` | s/ senha | `pm2 monit` / `redis-cli` |
|
||||
| **Python (3.14)**| Ativo | - | - | `python --version` |
|
||||
| **PM2** | Ativo | - | - | `pm2 list` |
|
||||
|
||||
### Comandos de Manutenção:
|
||||
- **Verificar Redis**: `pm2 list` ou `redis-cli ping`
|
||||
- **Verificar MySQL**: `Get-Service MySQL`
|
||||
- **Logs do Redis**: `pm2 logs redis-server`
|
||||
|
||||
---
|
||||
|
||||
## 📋 5. Comandos Úteis
|
||||
|
||||
| Objetivo | Comando |
|
||||
| :--- | :--- |
|
||||
| **Instalar Dependências** | `npm install` |
|
||||
| **Rodar Lançamento** | `node lancar-guia-odonto.js` |
|
||||
| **Verificar Versão Node** | `node -v` |
|
||||
| **Logs do Processo** | `pm2 logs` |
|
||||
|
||||
---
|
||||
|
||||
**Conclusão**: Esta automação transforma um processo de 5 minutos de cliques manuais em uma tarefa de 20 segundos totalmente segura e auditável.
|
||||
@@ -0,0 +1,63 @@
|
||||
# 🦷 Projeto de Automação: Portal Viventeris (Cassems)
|
||||
|
||||
Este documento resume a estrutura e a lógica da automação desenvolvida para o lançamento de guias odontológicas no portal Viventeris.
|
||||
|
||||
## 🚀 Visão Geral
|
||||
A automação utiliza **Node.js** e **Playwright** para simular a interação humana com o portal, superando desafios de interface (UX) e automação de processos repetitivos.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tecnologias Utilizadas
|
||||
- **Linguagem**: JavaScript (Node.js)
|
||||
- **Engine**: Playwright (Chromium)
|
||||
- **Estratégia**: Interação via Seletores de ID, XPath e Filtros de Visibilidade.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Fluxo de Automação Compreendido
|
||||
|
||||
### 1. Acesso e Segurança
|
||||
- **Login**: Autenticação automática com User/Senha.
|
||||
- **User-Agent**: Configurado para evitar o bloqueio de "Navegador Incompatível".
|
||||
|
||||
### 2. Identificação do Beneficiário
|
||||
- Navega até o menu **Tratamento Odontológico**.
|
||||
- Realiza a busca via **CPF** (formato: `000.000.000-00`).
|
||||
- Seleciona o beneficiário clicando no link do cartão retornado na tabela.
|
||||
|
||||
### 3. Configuração do Atendimento
|
||||
- **Novo Registro**: Clica no botão `+` (`#btnNovoRegistro`).
|
||||
- **Endereço**: Seleciona automaticamente o primeiro endereço disponível se houver um select.
|
||||
- **Tipo de Atendimento**:
|
||||
- `Valor 1`: Tratamento Odontológico (Normal).
|
||||
- `Valor 4`: Urgência / Emergência.
|
||||
- **Regra de Justificativa**:
|
||||
- O campo `Observação/Justificativa` (`#observacaoJustificativa`) só é preenchido se o tipo for **Urgência (4)**. Caso contrário, permanece vazio.
|
||||
|
||||
### 4. Seleção de Profissionais
|
||||
- **Executante**: Utiliza o botão de busca (`#btnBuscaPrestadorExecutante`) ao lado do input.
|
||||
- **Solicitante**: Preenchimento fixo/variável do campo de texto (ex: "Murilo").
|
||||
|
||||
### 5. Lançamento de Procedimentos (Modal)
|
||||
- **Autocomplete**: Após digitar o código (ex: `85100196`), o script aguarda e clica obrigatoriamente no item da lista jQuery UI (`.ui-menu-item-wrapper`) para que o sistema valide a seleção.
|
||||
- **Dente**: Seleção via ID `#cmbDente`.
|
||||
- **Face**: Clique forçado no checkbox `#codFaceV` para evitar interceptação pela label de estilo.
|
||||
- **Inclusão**: Clica em `#btnIncluirProcedimento` dentro do modal.
|
||||
|
||||
### 6. Finalização e Captura
|
||||
- Avança as etapas de confirmação ("Próximo").
|
||||
- Extrai o número da **GTO** gerada e o **Status** (Autorizado/Negado).
|
||||
- Salva o resultado no arquivo `guias_geradas.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estrutura de Arquivos
|
||||
- `login-test.js`: Validação inicial de acesso.
|
||||
- `lancar-guia-odonto.js`: Script principal de produção.
|
||||
- `guias_geradas.txt`: Log histórico de GTOs geradas.
|
||||
- `procedimentos_encontrados.txt`: Dicionário de códigos capturados durante a exploração.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Observação Importante sobre Erros
|
||||
Conforme alinhado, o script possui **breakpoints de segurança**. Caso um elemento (como o nome de um profissional específico ou um campo novo) não seja encontrado, o script **PARA IMEDIATAMENTE** e aguarda intervenção manual, evitando o preenchimento de dados incorretos no portal oficial.
|
||||
@@ -0,0 +1,49 @@
|
||||
const axios = require('axios');
|
||||
const qs = require('qs');
|
||||
|
||||
async function testAxiosLogin() {
|
||||
console.log('🧪 Testando Login via Axios...');
|
||||
|
||||
const loginUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br/credenciado/Account/AutenticarCredenciado/?returnUrl=';
|
||||
|
||||
// Dados capturados no mapeamento
|
||||
const payload = qs.stringify({
|
||||
'usuario': '0005010',
|
||||
'senha': 'Cclinic#03',
|
||||
'token': '',
|
||||
'login-submit': 'ENTRAR'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(loginUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Origin': 'https://viventerisportalcredenciado.topsaudehub.com.br',
|
||||
'Referer': 'https://viventerisportalcredenciado.topsaudehub.com.br/'
|
||||
},
|
||||
maxRedirects: 0, // Queremos ver para onde ele nos manda
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
console.log('\n✅ Login enviado com sucesso!');
|
||||
console.log('Status:', response.status);
|
||||
console.log('Cookies recebidos:', response.headers['set-cookie']);
|
||||
console.log('Redirecionado para:', response.headers.location);
|
||||
|
||||
if (response.headers.location && response.headers.location.includes('AreaLogada')) {
|
||||
console.log('\n🔥 SUCESSO! O servidor aceitou o login via Axios.');
|
||||
} else {
|
||||
console.log('\n⚠️ O login parece ter sido recusado ou mudou de caminho.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Erro no Login Axios:', error.message);
|
||||
if (error.response) {
|
||||
console.log('Status do Erro:', error.response.status);
|
||||
console.log('Data:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testAxiosLogin();
|
||||
@@ -0,0 +1,217 @@
|
||||
const axios = require('axios');
|
||||
const qs = require('qs');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// Configurações
|
||||
const CREDENTIALS = {
|
||||
usuario: '0005010',
|
||||
senha: 'Cclinic#03'
|
||||
};
|
||||
|
||||
const DB_CONFIG = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'cassems_automation'
|
||||
};
|
||||
|
||||
class CassemsRobot {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://viventeris.topsaudehub.com.br/api/APICredenciado/api',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
}
|
||||
});
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
console.log('🔐 Autenticando no portal...');
|
||||
const loginUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br/credenciado/Account/AutenticarCredenciado/?returnUrl=';
|
||||
|
||||
const payload = qs.stringify({
|
||||
...CREDENTIALS,
|
||||
'token': '',
|
||||
'login-submit': 'ENTRAR'
|
||||
});
|
||||
|
||||
const res = await axios.post(loginUrl, payload, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
maxRedirects: 0,
|
||||
validateStatus: s => s < 400
|
||||
});
|
||||
|
||||
// Extrair Token do Cookie
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const tokenCookie = cookies.find(c => c.includes('TOKEN=bearer'));
|
||||
|
||||
if (!tokenCookie) {
|
||||
console.log('Cookies recebidos:', cookies);
|
||||
throw new Error('Token não encontrado nos cookies de resposta.');
|
||||
}
|
||||
|
||||
// Decodificar o token da URL (substituir %20 por espaço se necessário)
|
||||
const rawToken = tokenCookie.split('TOKEN=')[1].split(';')[0];
|
||||
this.token = decodeURIComponent(rawToken).replace('bearer ', '');
|
||||
|
||||
this.client.defaults.headers.common['Authorization'] = `bearer ${this.token}`;
|
||||
|
||||
console.log('✅ Autenticado com sucesso! Token JWT capturado.');
|
||||
}
|
||||
|
||||
async buscarBeneficiario(cpf) {
|
||||
const cpfLimpo = cpf.replace(/\D/g, '');
|
||||
console.log(`🔍 Buscando beneficiário CPF: ${cpf}...`);
|
||||
|
||||
const payload = {
|
||||
codPrestadorTs: "501",
|
||||
numAssociado: "",
|
||||
numCpf: cpfLimpo,
|
||||
numRg: "",
|
||||
nomeAssociado: "",
|
||||
numPedido: "",
|
||||
numProtocoloAns: "",
|
||||
tipoPrestador: "D"
|
||||
};
|
||||
|
||||
const res = await this.client.post('/beneficiario/buscaBeneficiario', payload);
|
||||
|
||||
if (res.data && res.data.length > 0) {
|
||||
const b = res.data[0];
|
||||
console.log('DEBUG - Estrutura do Beneficiário:', JSON.stringify(b, null, 2));
|
||||
|
||||
// Mapeamento correto conforme o DEBUG
|
||||
b.nome_final = b.nome_associado || b.NOM_PESSOA || "NOME DESCONHECIDO";
|
||||
b.id_final = b.cod_ts || b.COD_TS || "ID DESCONHECIDO";
|
||||
|
||||
console.log(`👤 Beneficiário Encontrado: ${b.nome_final} (ID: ${b.id_final})`);
|
||||
return b;
|
||||
}
|
||||
|
||||
console.log('⚠️ Beneficiário não encontrado.');
|
||||
return null;
|
||||
}
|
||||
|
||||
async lancarGuia(beneficiario, procedimento, dente, face) {
|
||||
console.log(`🚀 Lançando guia para ${beneficiario.nome_final}...`);
|
||||
|
||||
const dataHoje = new Date().toLocaleDateString('pt-BR');
|
||||
|
||||
const payload = {
|
||||
"dadosAtendimento": [{
|
||||
"codPrestadorTs": "501",
|
||||
"codTs": beneficiario.cod_ts,
|
||||
"numAssociado": beneficiario.num_associado,
|
||||
"nomeAssociado": beneficiario.nome_associado,
|
||||
"indSexo": beneficiario.indSexo || "",
|
||||
"codPlano": beneficiario.codPlano || "",
|
||||
"codEmpresa": beneficiario.codEmpresa || "",
|
||||
"numCPF": beneficiario.cpf || "",
|
||||
"numContrato": beneficiario.numContrato || "",
|
||||
"numAssociadoTitular": beneficiario.numAssociadoTitular || "",
|
||||
"nomeAssociadoTitular": beneficiario.nomeAssociadoTitular || "",
|
||||
"nomeContrato": beneficiario.nomeContrato || "",
|
||||
"num_ddd_cel_contato": "",
|
||||
"num_cel_contato": "",
|
||||
"num_ddd_res_contato": "",
|
||||
"num_res_contato": "",
|
||||
"txt_end_email_contato": ""
|
||||
}],
|
||||
"dadosProfissional": [{
|
||||
"codProExecutanteTs": "502",
|
||||
"atendimentoRN": "N",
|
||||
"enderecoAtendimento": "1",
|
||||
"cnes": "9999999",
|
||||
"dataSolicitacao": dataHoje,
|
||||
"tipoAtendimento": "1",
|
||||
"nomeProfissionalExecutante": "RUI CESAR VARGAS CASANOVA",
|
||||
"croExecutante": "07381",
|
||||
"ufExecutante": "MS",
|
||||
"cboExecutante": "",
|
||||
"nomeProfissionalSolicitante": "Murilo",
|
||||
"croSolicitante": "",
|
||||
"ufSolicitante": "",
|
||||
"codCboSolicitante": "",
|
||||
"observacaoJustificativa": ""
|
||||
}],
|
||||
"autorizacaoItem": [{
|
||||
"num_seq_item": 1,
|
||||
"item_medico": procedimento,
|
||||
"nome_item": "PROCEDIMENTO VIA ROBÔ",
|
||||
"dente_regiao": dente,
|
||||
"cod_face": face,
|
||||
"ind_rx_odonto": "N",
|
||||
"ind_foto": "N",
|
||||
"ind_laudo": "N",
|
||||
"qtd_procedimento": "1"
|
||||
}],
|
||||
"situacaoInicial": [],
|
||||
"ortodontiaPartial": [],
|
||||
"prorrogacaoPartial": [],
|
||||
"ExisteProrrogacao": "N",
|
||||
"ExisteOrtodontia": "N",
|
||||
"TratamentoTransferido": "N",
|
||||
"NumeroTratamento": "",
|
||||
"CodUsuario": "0005010",
|
||||
"NumSeqPedidoUltimo": ""
|
||||
};
|
||||
|
||||
const res = await this.client.post('/autorizacao/NovoTratamento', payload);
|
||||
|
||||
if (res.data) {
|
||||
console.log('✅ Guia Processada com Sucesso!');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
throw new Error('Falha no processamento da guia.');
|
||||
}
|
||||
|
||||
async registrarNoBanco(dados) {
|
||||
const connection = await mysql.createConnection(DB_CONFIG);
|
||||
await connection.execute(
|
||||
'INSERT INTO guias_pendentes (cpf, procedimento, dente, face, status, gto) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[dados.cpf, dados.procedimento, dados.dente, dados.face, dados.status, dados.gto || null]
|
||||
);
|
||||
console.log('💾 Registro salvo no MySQL.');
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Execução de Teste
|
||||
(async () => {
|
||||
const robot = new CassemsRobot();
|
||||
try {
|
||||
await robot.login();
|
||||
|
||||
const cpf = '005.983.051-49';
|
||||
const proc = '85100196';
|
||||
const dente = '11';
|
||||
const face = 'V';
|
||||
|
||||
const beneficiario = await robot.buscarBeneficiario(cpf);
|
||||
|
||||
if (beneficiario) {
|
||||
const resultado = await robot.lancarGuia(beneficiario, proc, dente, face);
|
||||
console.log('Dados do Retorno da API:', JSON.stringify(resultado, null, 2));
|
||||
|
||||
// Tenta capturar o número da guia se existir no JSON
|
||||
const numeroGuia = resultado.NumeroTratamento || resultado.numGuia || "VER_LOG";
|
||||
|
||||
await robot.registrarNoBanco({
|
||||
cpf: cpf,
|
||||
procedimento: proc,
|
||||
dente: dente,
|
||||
face: face,
|
||||
status: 'AUTORIZADO (AXIOS)',
|
||||
gto: numeroGuia
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n🔥 SUCESSO TOTAL! Guia lançada via API em milissegundos.');
|
||||
} catch (e) {
|
||||
console.error('❌ Erro no Robô:', e.message);
|
||||
if (e.response) console.log('Detalhes:', JSON.stringify(e.response.data, null, 2));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,51 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
console.log("Connected to DB");
|
||||
|
||||
try {
|
||||
const [users] = await connection.query('SELECT count(*) as count FROM usuarios');
|
||||
console.log("Users count:", users[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'usuarios' not found or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const [links] = await connection.query('SELECT count(*) as count FROM vinculos');
|
||||
console.log("Links count:", links[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'vinculos' not found or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const [clinics] = await connection.query('SELECT count(*) as count FROM clinicas');
|
||||
console.log("Clinics count:", clinics[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'clinicas' not found or error:", e.message);
|
||||
}
|
||||
|
||||
const [usersData] = await connection.query('SELECT id, email, senha FROM usuarios');
|
||||
console.log("Users in DB (email/password):");
|
||||
usersData.forEach(u => console.log(`- ${u.email} / ${u.senha}`));
|
||||
|
||||
for (const user of usersData) {
|
||||
const [userLinks] = await connection.query('SELECT * FROM vinculos WHERE usuario_id = ?', [user.id]);
|
||||
console.log(`Links for ${user.email} (ID: ${user.id}):`, userLinks.map(l => ({ clinica: l.clinica_id, role: l.role })));
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
} catch (err) {
|
||||
console.error("DB Check failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
@@ -0,0 +1,69 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
async function fix() {
|
||||
try {
|
||||
const c = await mysql.createConnection(dbConfig);
|
||||
console.log("Connected for fixing collations...");
|
||||
|
||||
const tables = [
|
||||
'usuarios', 'clinicas', 'vinculos', 'dentistas', 'agendamentos',
|
||||
'pacientes', 'financeiro', 'planos', 'especialidades', 'procedimentos',
|
||||
'procedimentos_valores_planos', 'guias_odontologicas', 'gto_builders', 'gto_items', 'notificacoes', 'settings'
|
||||
];
|
||||
|
||||
for (const t of tables) {
|
||||
try {
|
||||
// Remove FK checks to avoid issues during alter
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
await c.query(`ALTER TABLE ${t} CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
console.log(`[OK] ${t} normalized.`);
|
||||
} catch (err) {
|
||||
console.log(`[SKIP] ${t}: ${err.message}`);
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
}
|
||||
|
||||
// Link unassigned dentists (professionals) to the main clinic
|
||||
await c.query('UPDATE dentistas SET clinica_id = "c1" WHERE clinica_id IS NULL OR clinica_id = ""');
|
||||
console.log("All dentists linked to c1 (Matriz).");
|
||||
|
||||
// Now get the division
|
||||
const [division] = await c.query(`
|
||||
SELECT
|
||||
c.nome_fantasia as Clinica,
|
||||
u.nome as Nome,
|
||||
v.role as Papel,
|
||||
u.email as Email
|
||||
FROM vinculos v
|
||||
JOIN usuarios u ON v.usuario_id = u.id
|
||||
JOIN clinicas c ON v.clinica_id = c.id
|
||||
WHERE v.role IN ('dentista', 'donoclinica')
|
||||
ORDER BY c.nome_fantasia
|
||||
`);
|
||||
|
||||
console.log("\n--- DIVISÃO DE DENTISTAS/DONOS POR CLÍNICA ---");
|
||||
console.table(division);
|
||||
|
||||
const [professionals] = await c.query(`
|
||||
SELECT d.nome, c.nome_fantasia as clinica
|
||||
FROM dentistas d
|
||||
JOIN clinicas c ON d.clinica_id = c.id
|
||||
`);
|
||||
console.log("\n--- REGISTROS PROFISSIONAIS (LISTA DE DENTISTAS) ---");
|
||||
console.table(professionals);
|
||||
|
||||
await c.end();
|
||||
} catch (err) {
|
||||
console.error("Fix failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
fix();
|
||||
@@ -0,0 +1,40 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
async function run() {
|
||||
const c = await mysql.createConnection({
|
||||
host: 'localhost', user: 'root', password: '', database: 'sis_odonto'
|
||||
});
|
||||
|
||||
// 1. Seed missing plans (those referenced by existing procedimentos_valores_planos)
|
||||
const plans = [
|
||||
{ id: 'p2', nome: 'UNIMED ODONTO', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#1eb545' },
|
||||
{ id: 'p3', nome: 'ODONTOPREV', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#0066cc' },
|
||||
{ id: 'p4', nome: 'CASSEMS', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#e63946' },
|
||||
{ id: 'p5', nome: 'SESI ODONTO', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#f4a261' },
|
||||
];
|
||||
|
||||
for (const p of plans) {
|
||||
await c.query(
|
||||
'INSERT IGNORE INTO planos (id, nome, tipo, descontoPadrao, corCartao) VALUES (?, ?, ?, ?, ?)',
|
||||
[p.id, p.nome, p.tipo, p.descontoPadrao, p.corCartao]
|
||||
);
|
||||
}
|
||||
console.log('Plans seeded.');
|
||||
|
||||
// 2. Drop the FK on planoId to make the relation soft (plan can be deleted without cascading)
|
||||
// This prevents the ER_NO_REFERENCED_ROW_2 on INSERT
|
||||
try {
|
||||
await c.query('ALTER TABLE procedimentos_valores_planos DROP FOREIGN KEY procedimentos_valores_planos_ibfk_2');
|
||||
console.log('FK on planoId dropped — now soft reference.');
|
||||
} catch (e) {
|
||||
console.log('FK drop skipped (may already not exist):', e.message);
|
||||
}
|
||||
|
||||
const [planos] = await c.query('SELECT id, nome, tipo FROM planos');
|
||||
console.log('\n--- PLANOS FINAIS ---');
|
||||
console.table(planos);
|
||||
|
||||
await c.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
TITLE Iniciar ScoreOdonto CRM
|
||||
echo.
|
||||
echo ==================================================
|
||||
echo INICIANDO SISTEMA SCOREODONTO - WINDOWS
|
||||
echo ==================================================
|
||||
echo.
|
||||
|
||||
:: 1. Entrar na pasta do projeto
|
||||
cd /d "%~dp0scoreodonto"
|
||||
|
||||
:: 2. Iniciar a API em uma nova janela
|
||||
echo [INFO] Iniciando Servidor API (Porta 3002)...
|
||||
start "" cmd /k "node server.js"
|
||||
|
||||
:: 3. Aguardar alguns segundos para a API estabilizar
|
||||
timeout /t 3 /nobreak > nul
|
||||
|
||||
:: 4. Iniciar o Frontend (Vite) na janela atual
|
||||
echo [INFO] Iniciando Frontend (Porta 3000)...
|
||||
echo.
|
||||
echo [DICA] O sistema abrira em: http://localhost:3000
|
||||
echo [DICA] Para fechar, feche as janelas do terminal.
|
||||
echo.
|
||||
npm run dev
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: cassems-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: cassems_automation
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
container_name: cassems-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MedLandingPage } from './LandingPage.tsx';
|
||||
import { MedLayout } from './MedLayout.tsx';
|
||||
import { MedDashboard } from './MedDashboard.tsx';
|
||||
import { MedicoBackend } from './services/medicoBackend.ts';
|
||||
|
||||
export type MedViewKey =
|
||||
| 'med-landing'
|
||||
| 'med-dashboard'
|
||||
| 'med-pacientes'
|
||||
| 'med-agenda'
|
||||
| 'med-prontuarios'
|
||||
| 'med-fidelidade'
|
||||
| 'med-financeiro'
|
||||
| 'medical-repasse';
|
||||
|
||||
const ROUTE_MAP: Record<string, MedViewKey> = {
|
||||
'/': 'med-landing',
|
||||
'/landingpage': 'med-landing',
|
||||
'/dashboard': 'med-dashboard',
|
||||
'/pacientes': 'med-pacientes',
|
||||
'/agenda': 'med-agenda',
|
||||
'/prontuarios': 'med-prontuarios',
|
||||
'/fidelidade': 'med-fidelidade',
|
||||
'/financeiro': 'med-financeiro',
|
||||
'/repasse': 'medical-repasse',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<MedViewKey, string> = {
|
||||
'med-landing': '/landingpage',
|
||||
'med-dashboard': '/dashboard',
|
||||
'med-pacientes': '/pacientes',
|
||||
'med-agenda': '/agenda',
|
||||
'med-prontuarios': '/prontuarios',
|
||||
'med-fidelidade': '/fidelidade',
|
||||
'med-financeiro': '/financeiro',
|
||||
'medical-repasse': '/repasse',
|
||||
};
|
||||
|
||||
function getHashPath(): string {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/')) return hash.slice(1);
|
||||
return '/';
|
||||
}
|
||||
|
||||
function resolveViewFromUrl(): MedViewKey {
|
||||
const path = getHashPath();
|
||||
return ROUTE_MAP[path] ?? 'med-landing';
|
||||
}
|
||||
|
||||
function navigate(view: MedViewKey) {
|
||||
window.location.hash = VIEW_TO_ROUTE[view];
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentView, setCurrentView] = useState<MedViewKey>(resolveViewFromUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
setCurrentView(resolveViewFromUrl());
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
const handleNavigate = (view: string) => {
|
||||
const key = view as MedViewKey;
|
||||
setCurrentView(key);
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'med-landing': return <MedLandingPage />;
|
||||
case 'med-dashboard': return <MedDashboard />;
|
||||
case 'med-pacientes': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Módulo de Pacientes Médicos <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-agenda': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Agenda Médica Premium <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-prontuarios': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Sistema de Prontuários <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-fidelidade': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Gestão de Fidelidade <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-financeiro': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Financeiro & MRR <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'medical-repasse': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Repasse Médico <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
default: return <MedLandingPage />;
|
||||
}
|
||||
};
|
||||
|
||||
if (currentView === 'med-landing') {
|
||||
return <MedLandingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MedLayout currentView={currentView} onNavigate={handleNavigate}>
|
||||
{renderView()}
|
||||
</MedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Shield, Activity, Heart, Check, ArrowRight, Star, Clock, Users, Smartphone, Zap } from 'lucide-react';
|
||||
|
||||
export const MedLandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white font-sans selection:bg-cyan-500 selection:text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-slate-900/50 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tighter uppercase italic">CRM MÉDICO <span className="text-cyan-400">FIDELIDADE</span></span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-bold uppercase tracking-widest text-slate-400">
|
||||
<a href="#planos" className="hover:text-white transition-colors">Planos</a>
|
||||
<a href="#beneficios" className="hover:text-white transition-colors">Benefícios</a>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="hover:text-white transition-colors uppercase"
|
||||
>
|
||||
Acesso Restrito
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="px-6 py-2.5 bg-white text-slate-900 rounded-full hover:bg-cyan-400 transition-all hover:-translate-y-1"
|
||||
>
|
||||
Voltar ao CRM Odonto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-40 pb-20 overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-cyan-500/10 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-blue-600/10 rounded-full blur-[120px] animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-full text-xs font-black uppercase tracking-widest text-cyan-400 mb-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Zap size={14} /> Novo: Fidelidade Multimédica
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-black tracking-tighter leading-[0.9] text-white mb-8 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||
SAÚDE TOTAL, <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600">SEM COMPROMISSO.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 max-w-xl leading-relaxed mb-12 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200">
|
||||
O primeiro plano de fidelidade que integra medicina, odontologia e bem-estar em um único ecossistema. Fidelize sua saúde com tecnologia de ponta.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 animate-in fade-in slide-in-from-bottom-10 duration-1000 delay-500">
|
||||
<button className="px-10 py-5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-2xl font-black uppercase tracking-widest hover:scale-105 transition-all shadow-xl shadow-cyan-500/20 flex items-center gap-3">
|
||||
COMEÇAR AGORA <ArrowRight size={20} />
|
||||
</button>
|
||||
<button className="px-10 py-5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl font-black uppercase tracking-widest transition-all">
|
||||
VER PLANOS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
<section id="planos" className="py-32 bg-slate-900/40 relative">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">ESTRUTURA DE <span className="text-cyan-400">PLANOS</span></h2>
|
||||
<p className="text-slate-400 uppercase tracking-[0.3em] font-bold text-xs">Exclusivo: Combos que não aceitam desmembramento</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Base Plan */}
|
||||
<div className="group relative bg-white/5 border border-white/10 rounded-[3rem] p-12 hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute top-8 right-8 text-slate-700 font-black text-6xl group-hover:text-slate-600 transition-colors">01</div>
|
||||
<div className="mb-10">
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA BASE (OBRIGATÓRIO)</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + <br />ODONTO</h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Consultas Médicas ilimitadas',
|
||||
'Limpeza e Prevenção Dental',
|
||||
'Urgências 24h',
|
||||
'Rede Credenciada Platinum',
|
||||
'Descontos em Medicamentos'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 149</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all">
|
||||
SELECIONAR PLANO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Premium Plan */}
|
||||
<div className="group relative bg-white/5 border-2 border-cyan-500/50 rounded-[3rem] p-12 overflow-hidden hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="absolute top-8 right-8 text-cyan-900/30 font-black text-6xl group-hover:text-cyan-900/50 transition-colors">02</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-4 py-1.5 bg-gradient-to-r from-amber-400 to-orange-500 text-black text-[10px] font-black uppercase tracking-widest rounded-full">MAIS POPULAR</span>
|
||||
</div>
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA PREMIUM</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + ODONTO <br /><span className="text-cyan-400">+ PSICOLOGIA</span></h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Tudo da Linha Base',
|
||||
'Suporte Psicológico 24h',
|
||||
'Sessões de Terapia Mensais',
|
||||
'Check-up Executivo Anual',
|
||||
'Prioridade na Agenda (VIP)'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500 flex items-center justify-center text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 249</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-gradient-to-r from-cyan-500 to-blue-600 text-white rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] transition-all shadow-lg shadow-cyan-500/20">
|
||||
ASSINAR PREMIUM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features (CRM) */}
|
||||
<section id="beneficios" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ icon: Users, title: 'TITULAR E DEP.', desc: 'Gestão completa familiar com carteirinha individual digital.' },
|
||||
{ icon: Shield, title: 'ESTABILIDADE', desc: 'Previsibilidade total para o médico e segurança para o paciente.' },
|
||||
{ icon: Smartphone, title: 'WALLET DIGITAL', desc: 'QR Code e histórico médico sempre no seu bolso.' },
|
||||
{ icon: Zap, title: 'SEM GLOSA', desc: 'Processamento automático e repasse médico instantâneo.' }
|
||||
].map((feat, i) => (
|
||||
<div key={i} className="p-8 bg-white/2 hover:bg-white/5 border border-white/5 rounded-3xl transition-all">
|
||||
<feat.icon className="text-cyan-400 mb-6" size={32} />
|
||||
<h4 className="text-lg font-black tracking-widest uppercase mb-4">{feat.title}</h4>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{feat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-white/5 bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
||||
<Activity className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="font-black uppercase tracking-tighter">CRM MÉDICO</span>
|
||||
</div>
|
||||
<div className="text-slate-500 text-[10px] font-black uppercase tracking-[0.4em]">
|
||||
© 2026 SCOREODONTO HYBRID MEDICAL ENGINE. ALL RIGHTS RESERVED.
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Sair para Odonto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Painel Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-in {
|
||||
animation: animate-in 0.8s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users, Calendar, DollarSign, Activity,
|
||||
ArrowUpRight, ArrowDownRight, Zap,
|
||||
TrendingUp, AlertCircle, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { MedicoBackend } from './services/medicoBackend.ts';
|
||||
import { MedicoNotif } from './services/medicoNotif.ts';
|
||||
import { MedPaciente, MedAgendamento, MedNotificacao } from './types.ts';
|
||||
|
||||
export const MedDashboard: React.FC = () => {
|
||||
const [pacientes, setPacientes] = useState<MedPaciente[]>([]);
|
||||
const [agendamentos, setAgendamentos] = useState<MedAgendamento[]>([]);
|
||||
const [notificacoes, setNotificacoes] = useState<MedNotificacao[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await MedicoNotif.generateIntelligentAlerts();
|
||||
const [p, a, n] = await Promise.all([
|
||||
MedicoBackend.getPacientes(),
|
||||
MedicoBackend.getAgendamentos(),
|
||||
MedicoBackend.getNotificacoes()
|
||||
]);
|
||||
setPacientes(p);
|
||||
setAgendamentos(a);
|
||||
setNotificacoes(n.filter(notif => !notif.lida));
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Pacientes Ativos', value: pacientes.length.toString(), change: '+12%', positive: true, icon: Users, color: 'cyan' },
|
||||
{ label: 'Consultas Hoje', value: agendamentos.filter(a => a.start.includes(new Date().toISOString().split('T')[0])).length.toString(), change: '+4', positive: true, icon: Calendar, color: 'blue' },
|
||||
{ label: 'Receita Est. (MRR)', value: 'R$ 84.200', change: '+8%', positive: true, icon: DollarSign, color: 'emerald' },
|
||||
{ label: 'Alertas Ativos', value: notificacoes.length.toString(), change: '', positive: true, icon: Activity, color: 'purple' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-20 text-center text-cyan-500 font-black uppercase tracking-[0.5em] animate-pulse">Iniciando Motor Médico...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-white mb-2 uppercase italic">Painel <span className="text-cyan-400">Estratégico</span></h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Visão geral do ecossistema médico e fidelidade</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={i} className="bg-white/5 border border-white/10 p-8 rounded-[2rem] hover:bg-white/[0.08] transition-all group">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-4 rounded-2xl bg-${stat.color}-500/10 text-${stat.color}-400 group-hover:scale-110 transition-transform`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-[10px] font-black uppercase tracking-widest ${stat.positive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{stat.change && (stat.positive ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />)}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-slate-500 text-[10px] font-black uppercase tracking-[0.2em]">{stat.label}</span>
|
||||
<div className="text-3xl font-black text-white tracking-tighter">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-[2.5rem] overflow-hidden">
|
||||
<div className="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.3em] text-white">Próximos Atendimentos</h3>
|
||||
<button className="text-[10px] font-black text-cyan-400 hover:text-cyan-300 uppercase tracking-widest transition-colors">Ver Agenda Completa</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{agendamentos.length > 0 ? agendamentos.slice(0, 5).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-all cursor-pointer group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-800 flex flex-col items-center justify-center border border-white/5">
|
||||
<span className="text-[10px] font-black text-slate-500 leading-none">HRS</span>
|
||||
<span className="text-sm font-black text-white">{new Date(item.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight group-hover:text-cyan-400 transition-colors">{item.pacienteNome}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">{item.tipo}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest ${item.status === 'Confirmado' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<button className="p-2 text-slate-600 hover:text-white transition-colors">
|
||||
<ArrowUpRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="p-10 text-center text-slate-500 font-bold uppercase tracking-widest text-xs">Nenhum agendamento para hoje</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intelligent Insights */}
|
||||
<div className="bg-gradient-to-b from-cyan-600/20 to-transparent border border-cyan-500/20 rounded-[2.5rem] p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-cyan-500 rounded-2xl flex items-center justify-center text-white mb-8 shadow-lg shadow-cyan-500/20">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white tracking-tighter mb-4 italic uppercase">Inteligência <br />Médica</h3>
|
||||
<div className="space-y-6">
|
||||
{notificacoes.length > 0 ? notificacoes.slice(0, 2).map((notif, i) => (
|
||||
<div key={i} className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={14} className={notif.priority === 'high' ? 'text-rose-400' : 'text-amber-400'} />
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest ${notif.priority === 'high' ? 'text-rose-400' : 'text-amber-400'}`}>
|
||||
{notif.titulo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">{notif.mensagem}</p>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5 text-center">
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Sem alertas pendentes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="w-full mt-10 py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all flex items-center justify-center gap-3">
|
||||
Ver Insights <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MedSidebar } from './MedSidebar.tsx';
|
||||
import { Bell, Search, Settings, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface MedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentView: string;
|
||||
onNavigate: (view: any) => void;
|
||||
}
|
||||
|
||||
export const MedLayout: React.FC<MedLayoutProps> = ({ children, currentView, onNavigate }) => {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#020617] text-slate-200 overflow-hidden font-sans">
|
||||
{/* Sidebar */}
|
||||
<MedSidebar activeTab={currentView} setActiveTab={onNavigate} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 ml-72 flex flex-col h-screen overflow-hidden">
|
||||
{/* Premium Top Bar */}
|
||||
<header className="h-24 px-10 flex items-center justify-between border-b border-white/5 bg-[#020617]/50 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div className="flex items-center gap-6 flex-1 max-w-xl">
|
||||
<div className="relative group w-full">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-400 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="BUSCAR PACIENTES, AGENDAMENTOS OU GUIAS..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-6 text-xs font-bold uppercase tracking-widest focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.08] transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ icon: Bell, alert: true },
|
||||
{ icon: HelpCircle, alert: false },
|
||||
{ icon: Settings, alert: false }
|
||||
].map((item, i) => (
|
||||
<button key={i} className="w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-all relative">
|
||||
<item.icon size={20} className="text-slate-400" />
|
||||
{item.alert && <div className="absolute top-3 right-3 w-2.5 h-2.5 bg-cyan-500 border-2 border-[#020617] rounded-full"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-white/10 mx-2"></div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-2">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-cyan-500 block leading-none uppercase tracking-widest">Acesso Premium</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-tighter">Portal Médico Cassems</span>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-cyan-500 to-blue-600 p-[2px] shadow-lg shadow-cyan-500/20">
|
||||
<div className="w-full h-full rounded-full bg-slate-900 flex items-center justify-center text-xs font-black text-white">
|
||||
MED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Dashboard/View Container */}
|
||||
<main className="flex-1 overflow-y-auto p-10 custom-scrollbar">
|
||||
<div className="max-w-7xl mx-auto pb-20">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, ClipboardList,
|
||||
Activity, Shield, DollarSign, LogOut, Settings,
|
||||
ChevronRight, Zap, Heart, Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MedSidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const MedSidebar: React.FC<MedSidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const menuItems = [
|
||||
{ id: 'med-dashboard', label: 'Painel Geral', icon: LayoutDashboard },
|
||||
{ id: 'med-pacientes', label: 'Pacientes', icon: Users },
|
||||
{ id: 'med-agenda', label: 'Agenda Médica', icon: Calendar },
|
||||
{ id: 'med-prontuarios', label: 'Prontuários', icon: ClipboardList },
|
||||
{ id: 'med-fidelidade', label: 'Planos Fidelidade', icon: Shield },
|
||||
{ id: 'med-financeiro', label: 'Financeiro / MRR', icon: DollarSign },
|
||||
{ id: 'medical-repasse', label: 'Repasse Médico', icon: Zap },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('Deseja realmente sair do portal médico?')) {
|
||||
window.location.hash = '/medico/landingpage';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-[#0f172a] h-screen flex flex-col border-r border-white/5 fixed left-0 top-0 z-50 overflow-hidden">
|
||||
{/* Logo Section */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveTab('med-dashboard')}>
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20 group-hover:scale-110 transition-transform">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-black tracking-tighter text-white block leading-none italic">CRM <span className="text-cyan-400">MÉDICO</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Premium Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 space-y-2 overflow-y-auto py-4">
|
||||
<div className="px-4 mb-4">
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-[0.3em]">Navegação Principal</span>
|
||||
</div>
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3.5 rounded-xl transition-all group ${isActive
|
||||
? 'bg-gradient-to-r from-cyan-500/10 to-transparent border-l-4 border-cyan-500 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={20} className={isActive ? 'text-cyan-400' : 'group-hover:text-cyan-400 transition-colors'} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <ChevronRight size={14} className="text-cyan-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section / User Profile */}
|
||||
<div className="p-6 bg-slate-900/40 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 mb-6 px-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 border border-white/10 flex items-center justify-center text-cyan-400 font-black text-xs">
|
||||
DR
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-black text-white block truncate uppercase tracking-tighter">Dr. Profissional</span>
|
||||
<span className="text-[10px] text-cyan-500/80 font-bold uppercase tracking-widest">Médico Diretor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all text-xs font-bold uppercase tracking-widest">
|
||||
<Settings size={18} />
|
||||
Configurações
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sair do Portal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="px-8 py-4 bg-slate-950/50 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,211,238,0.5)]"></div>
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Sincronizado</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #020617;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CRM Médico | Fidelidade Multimédica</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
+2543
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "medico-crm",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
MedPaciente,
|
||||
MedProfissional,
|
||||
MedAgendamento,
|
||||
MedProntuario,
|
||||
MedFinanceiro,
|
||||
MedNotificacao
|
||||
} from '../types.ts';
|
||||
|
||||
const API_BASE_URL = '/api/med'; // New namespace for medical API
|
||||
|
||||
export class MedicoBackend {
|
||||
// --- STORAGE KEYS ---
|
||||
private static KEYS = {
|
||||
PACIENTES: 'med_pacientes',
|
||||
PROFISSIONAIS: 'med_profissionais',
|
||||
AGENDAMENTOS: 'med_agendamentos',
|
||||
PRONTUARIOS: 'med_prontuarios',
|
||||
FINANCEIRO: 'med_financeiro',
|
||||
NOTIFICACOES: 'med_notificacoes',
|
||||
AUTH: 'med_auth_token'
|
||||
};
|
||||
|
||||
// --- HELPERS ---
|
||||
private static async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem(this.KEYS.AUTH);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
|
||||
if (!response.ok) throw new Error(`API Error: ${response.statusText}`);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.warn(`API unavailable at ${path}, falling back to local storage.`, err);
|
||||
throw err; // Let the caller decide how to handle fallback
|
||||
}
|
||||
}
|
||||
|
||||
// --- PACIENTES ---
|
||||
static async getPacientes(): Promise<MedPaciente[]> {
|
||||
try {
|
||||
return await this.request<MedPaciente[]>('/pacientes');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.PACIENTES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async savePaciente(paciente: MedPaciente): Promise<void> {
|
||||
try {
|
||||
await this.request('/pacientes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(paciente)
|
||||
});
|
||||
} catch {
|
||||
const pacientes = await this.getPacientes();
|
||||
const index = pacientes.findIndex(p => p.id === paciente.id);
|
||||
if (index >= 0) pacientes[index] = paciente;
|
||||
else pacientes.push(paciente);
|
||||
localStorage.setItem(this.KEYS.PACIENTES, JSON.stringify(pacientes));
|
||||
}
|
||||
}
|
||||
|
||||
// --- AGENDA / PROFISSIONAIS ---
|
||||
static async getProfissionais(): Promise<MedProfissional[]> {
|
||||
try {
|
||||
return await this.request<MedProfissional[]>('/profissionais');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.PROFISSIONAIS);
|
||||
if (stored) return JSON.parse(stored);
|
||||
// Mock initial data if empty
|
||||
const mock: MedProfissional[] = [
|
||||
{ id: '1', nome: 'Dr. Ricardo Santos', crm: '12345-MS', especialidade: 'Cardiologia', email: 'ricardo@med.com', telefone: '67 9999-9999', ativo: true, corAgenda: '#22d3ee' },
|
||||
{ id: '2', nome: 'Dra. Ana Oliveira', crm: '67890-MS', especialidade: 'Pediatria', email: 'ana@med.com', telefone: '67 8888-8888', ativo: true, corAgenda: '#f43f5e' }
|
||||
];
|
||||
localStorage.setItem(this.KEYS.PROFISSIONAIS, JSON.stringify(mock));
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
|
||||
static async getAgendamentos(): Promise<MedAgendamento[]> {
|
||||
try {
|
||||
return await this.request<MedAgendamento[]>('/agendamentos');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.AGENDAMENTOS);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async saveAgendamento(agenda: MedAgendamento): Promise<void> {
|
||||
try {
|
||||
await this.request('/agendamentos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(agenda)
|
||||
});
|
||||
} catch {
|
||||
const data = await this.getAgendamentos();
|
||||
const index = data.findIndex(a => a.id === agenda.id);
|
||||
if (index >= 0) data[index] = agenda;
|
||||
else data.push(agenda);
|
||||
localStorage.setItem(this.KEYS.AGENDAMENTOS, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// --- FINANCEIRO ---
|
||||
static async getFinanceiro(): Promise<MedFinanceiro[]> {
|
||||
try {
|
||||
return await this.request<MedFinanceiro[]>('/financeiro');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.FINANCEIRO);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- NOTIFICACOES ---
|
||||
static async getNotificacoes(): Promise<MedNotificacao[]> {
|
||||
try {
|
||||
return await this.request<MedNotificacao[]>('/notificacoes');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.NOTIFICACOES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async markNotificationRead(id: string): Promise<void> {
|
||||
try {
|
||||
await this.request(`/notificacoes/${id}/read`, { method: 'PUT' });
|
||||
} catch {
|
||||
const notifs = await this.getNotificacoes();
|
||||
const index = notifs.findIndex(n => n.id === id);
|
||||
if (index >= 0) {
|
||||
notifs[index].lida = true;
|
||||
localStorage.setItem(this.KEYS.NOTIFICACOES, JSON.stringify(notifs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AUTH ---
|
||||
static isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem(this.KEYS.AUTH);
|
||||
}
|
||||
|
||||
static login(token: string) {
|
||||
localStorage.setItem(this.KEYS.AUTH, token);
|
||||
}
|
||||
|
||||
static logout() {
|
||||
localStorage.removeItem(this.KEYS.AUTH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MedNotificacao, MedAgendamento, MedFinanceiro } from '../types.ts';
|
||||
import { MedicoBackend } from './medicoBackend.ts';
|
||||
|
||||
/**
|
||||
* Intelligent Notification Engine for Medical Module
|
||||
*/
|
||||
export class MedicoNotif {
|
||||
static async generateIntelligentAlerts(): Promise<void> {
|
||||
const agendamentos = await MedicoBackend.getAgendamentos();
|
||||
const financeiro = await MedicoBackend.getFinanceiro();
|
||||
const existingNotifs = await MedicoBackend.getNotificacoes();
|
||||
|
||||
const newNotifs: MedNotificacao[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// 1. Check for upcoming appointments (next 24h)
|
||||
agendamentos.forEach(ag => {
|
||||
const agDate = new Date(ag.start);
|
||||
const diffHours = (agDate.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours > 0 && diffHours < 24 && ag.status === 'Agendado') {
|
||||
const title = 'Confirmação de Agenda';
|
||||
const msg = `Paciente ${ag.pacienteNome} tem agendamento amanhã às ${agDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
if (!this.exists(existingNotifs, title, msg)) {
|
||||
newNotifs.push(this.create(title, msg, 'agenda', 'medium'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check for overdue payments
|
||||
financeiro.forEach(fin => {
|
||||
const venc = new Date(fin.dataVencimento);
|
||||
if (fin.status === 'Pendente' && venc < now) {
|
||||
const title = 'Pagamento em Atraso';
|
||||
const msg = `Mensalidade do paciente com ID ${fin.pacienteId} está atrasada desde ${venc.toLocaleDateString()}.`;
|
||||
|
||||
if (!this.exists(existingNotifs, title, msg)) {
|
||||
newNotifs.push(this.create(title, msg, 'financeiro', 'high'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newNotifs.length > 0) {
|
||||
const allNotifs = [...newNotifs, ...existingNotifs];
|
||||
localStorage.setItem('med_notificacoes', JSON.stringify(allNotifs));
|
||||
}
|
||||
}
|
||||
|
||||
private static create(titulo: string, mensagem: string, tipo: MedNotificacao['tipo'], priority: MedNotificacao['priority']): MedNotificacao {
|
||||
return {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
titulo,
|
||||
mensagem,
|
||||
tipo,
|
||||
priority,
|
||||
data: new Date().toISOString(),
|
||||
lida: false
|
||||
};
|
||||
}
|
||||
|
||||
private static exists(list: MedNotificacao[], title: string, msg: string): boolean {
|
||||
return list.some(n => n.titulo === title && n.mensagem === msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* MEDICAL MODULE TYPES
|
||||
* Standalone engine for the Medical CRM + Loyalty system
|
||||
*/
|
||||
|
||||
export interface MedPaciente {
|
||||
id: string;
|
||||
nome: string;
|
||||
cpf: string;
|
||||
telefone: string;
|
||||
email: string;
|
||||
dataNascimento: string;
|
||||
planoTipo: 'Base' | 'Premium'; // Médico + Odonto vs Médico + Odonto + Psico
|
||||
statusFidelidade: 'Ativo' | 'Inativo' | 'Inadimplente';
|
||||
ultimoAtendimento?: string;
|
||||
}
|
||||
|
||||
export interface MedProfissional {
|
||||
id: string;
|
||||
nome: string;
|
||||
crm: string;
|
||||
especialidade: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
ativo: boolean;
|
||||
horarios?: string;
|
||||
corAgenda: string;
|
||||
}
|
||||
|
||||
export interface MedAgendamento {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
pacienteNome: string;
|
||||
profissionalId: string;
|
||||
start: string; // ISO String
|
||||
end: string; // ISO String
|
||||
status: 'Agendado' | 'Confirmado' | 'Em Atendimento' | 'Finalizado' | 'Cancelado' | 'Falta';
|
||||
tipo: 'Consulta' | 'Retorno' | 'Exame' | 'Procedimento';
|
||||
observacoes?: string;
|
||||
cor?: string;
|
||||
}
|
||||
|
||||
export interface MedProntuario {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
data: string;
|
||||
profissionalId: string;
|
||||
anamnese: string;
|
||||
prescricao?: string;
|
||||
examesSolicitados?: string[];
|
||||
cid?: string[];
|
||||
anexos?: string[];
|
||||
}
|
||||
|
||||
export interface MedFinanceiro {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
descricao: string;
|
||||
valor: number;
|
||||
dataVencimento: string;
|
||||
dataPagamento?: string;
|
||||
status: 'Pendente' | 'Pago' | 'Atrasado';
|
||||
metodo: 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro';
|
||||
recorrencia: boolean; // Para o motor de fidelidade
|
||||
}
|
||||
|
||||
export interface MedFidelidadePlano {
|
||||
id: string;
|
||||
nome: string;
|
||||
valorMensal: number;
|
||||
inclusoOdonto: boolean; // Sempre true na base
|
||||
inclusoPsicologia: boolean; // True apenas no premium
|
||||
limiteConsultasMes: number;
|
||||
descontoMedicamentos: number;
|
||||
}
|
||||
|
||||
export interface MedNotificacao {
|
||||
id: string;
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
tipo: 'alerta' | 'financeiro' | 'agenda' | 'lead';
|
||||
data: string;
|
||||
lida: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface MedConfig {
|
||||
apiKey?: string;
|
||||
hospitalName: string;
|
||||
unidadeId: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
}
|
||||
});
|
||||
Generated
+595
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"axios": "^1.13.5",
|
||||
"mysql2": "^3.17.4",
|
||||
"playwright": "^1.58.2",
|
||||
"qs": "^6.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.17.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.17.4.tgz",
|
||||
"integrity": "sha512-RnfuK5tyIuaiPMWOCTTl4vQX/mQXqSA8eoIbwvWccadvPGvh+BYWWVecInMS5s7wcLUkze8LqJzwB/+A4uwuAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"axios": "^1.13.5",
|
||||
"mysql2": "^3.17.4",
|
||||
"playwright": "^1.58.2",
|
||||
"qs": "^6.15.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login para exploração...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login realizado. Explorando menu...');
|
||||
|
||||
// Esperar o menu carregar
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Listar menus principais
|
||||
const menuNames = ['Tratamento Odontológico', 'Elegibilidade', 'Faturamento', 'Solicitações Diversas'];
|
||||
|
||||
for (const name of menuNames) {
|
||||
console.log(`Clicando no menu: ${name}`);
|
||||
try {
|
||||
await page.click(`text="${name}"`, { timeout: 2000 });
|
||||
await page.waitForTimeout(1000);
|
||||
} catch (e) {
|
||||
console.log(`Erro ao clicar em ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tentar clicar em Solicitações Diversas -> Inclusão
|
||||
console.log('Navegando em Solicitações Diversas -> Inclusão...');
|
||||
try {
|
||||
await page.click('text="Solicitações Diversas"');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('text="Inclusão"');
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('URL após clicar em Inclusão:', page.url());
|
||||
} catch (e) {
|
||||
console.log('Falha ao seguir caminho Inclusão:', e.message);
|
||||
}
|
||||
|
||||
// Listar links com palavras-chave
|
||||
const guiaLinks = await page.evaluate(() => {
|
||||
const keywords = ['guia', 'sadt', 'consulta', 'externa', 'recebimentos', 'faturamento', 'lote'];
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim(),
|
||||
href: a.getAttribute('href')
|
||||
})).filter(l => keywords.some(k => l.text.toLowerCase().includes(k)) || keywords.some(k => (l.href || '').toLowerCase().includes(k)));
|
||||
});
|
||||
|
||||
console.log('Links relacionados a guias encontrados:', JSON.stringify(guiaLinks, null, 2));
|
||||
|
||||
// Listar TODOS os links do DOM para ver os caminhos
|
||||
const allLinks = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim().replace(/\n/g, ' '),
|
||||
href: a.getAttribute('href'),
|
||||
visible: a.offsetParent !== null
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('--- TODOS OS LINKS ENCONTRADOS (VISÍVEIS E INVISÍVEIS) ---');
|
||||
allLinks.forEach(l => {
|
||||
if (l.text && !l.href.includes('void(0)') && !l.href.startsWith('#')) {
|
||||
console.log(`[${l.visible ? 'VISÍVEL' : 'OCULTO'}] ${l.text} -> ${l.href}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Tirar um "print" da estrutura do menu principal
|
||||
const menuStructure = await page.evaluate(() => {
|
||||
// Tenta encontrar o container principal do menu (geralmente nav ou ul com classes específicas)
|
||||
const nav = document.querySelector('nav, ul.navbar-nav, .sidebar, #menu');
|
||||
return nav ? nav.innerText : 'Container de menu não identificado diretamente';
|
||||
});
|
||||
console.log('Estrutura de texto do menu:', menuStructure);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro na exploração:', error);
|
||||
} finally {
|
||||
console.log('Mantendo aberto para inspeção manual por 60 segundos...');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login OK. Navegando para Novo Registro (Guia)...');
|
||||
|
||||
// Tentar ir direto para a URL de novo registro
|
||||
const baseUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br';
|
||||
await page.goto(baseUrl + '/HomePortalCredenciado/WorkFlow/NovoRegistro', { waitUntil: 'networkidle' });
|
||||
|
||||
console.log('Página de Novo Registro carregada. Analisando campos...');
|
||||
await page.waitForTimeout(3000); // Esperar carregar formulário dinâmico
|
||||
|
||||
// Listar todos os inputs, selects e botões para entender o formulário de guias
|
||||
const formFields = await page.evaluate(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input, select, textarea, button'));
|
||||
return inputs.map(i => ({
|
||||
tagName: i.tagName,
|
||||
type: i.type,
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
placeholder: i.placeholder,
|
||||
labelText: i.labels?.[0]?.innerText || i.parentElement?.innerText?.split('\n')[0] || ''
|
||||
})).filter(i => i.id || i.name);
|
||||
});
|
||||
|
||||
console.log('CAMPOS DO FORMULÁRIO ENCONTRADOS:', JSON.stringify(formFields, null, 2));
|
||||
|
||||
// Verificar se há algum seletor de "Tipo de Guia"
|
||||
const tiposGuia = await page.evaluate(() => {
|
||||
const select = document.querySelector('select');
|
||||
if (select) {
|
||||
return Array.from(select.options).map(o => o.text);
|
||||
}
|
||||
return 'Nenhum select encontrado';
|
||||
});
|
||||
console.log('Opções de Guia/Select:', tiposGuia);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro:', error);
|
||||
} finally {
|
||||
console.log('Mantendo aberto para sua visualização por 60 segundos...');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,221 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('🚀 Iniciando Automação de Guia Odontológica...');
|
||||
|
||||
// LOGIN
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
|
||||
// 1. Tratamento Odontológico
|
||||
console.log('1. Acessando Tratamento Odontológico...');
|
||||
await page.click('text="Tratamento Odontológico"');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 2. Lupa de pesquisa
|
||||
console.log('2. Clicando na pesquisa (lupa)...');
|
||||
// Tentativa de achar o botão de busca que abre o modal de beneficiário
|
||||
const searchBtn = page.locator('.fa-search, .glyphicon-search, button[title*="Pesquisar"]').first();
|
||||
await searchBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. CPF do Beneficiário
|
||||
console.log('3. Inserindo CPF: 005.983.051-49...');
|
||||
// Esperar o campo ficar visível no modal
|
||||
const cpfInput = page.locator('input[id*="cpf"], input[name*="cpf"], .cpf-mask').first();
|
||||
await cpfInput.fill('005.983.051-49');
|
||||
|
||||
// 4. Pesquisar
|
||||
console.log('4. Clicando em Pesquisar...');
|
||||
await page.click('button:has-text("Pesquisar"), #btnPesquisarBeneficiario');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 5. Clica na coluna número beneficiário
|
||||
console.log('5. Selecionando beneficiário resultado...');
|
||||
// Espera especificamente por um link que pareça um número de cartão ou nome
|
||||
const linkBeneficiario = page.locator('table tbody tr td a').first();
|
||||
await linkBeneficiario.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await linkBeneficiario.click();
|
||||
await page.waitForTimeout(2000); // Espera carregar os dados dele
|
||||
|
||||
// 6. Botão +
|
||||
console.log('6. Clicando em Adicionar (+)...');
|
||||
// O botão + muitas vezes só aparece após selecionar o beneficiário
|
||||
const btnMais = page.locator('.fa-plus, .btn-success, #btnNovoRegistro, a:has-text("Novo"), button:has-text("+")').first();
|
||||
await btnMais.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btnMais.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 7. Dropdown tipo atendimento
|
||||
console.log('7. Selecionando Tipo de Atendimento...');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verifica se existe um select de endereço primeiro
|
||||
const enderecoSelect = page.locator('select[id*="endereco"], select[name*="endereco"]').first();
|
||||
if (await enderecoSelect.count() > 0) {
|
||||
console.log('Selecionando endereço de atendimento...');
|
||||
await enderecoSelect.selectOption({ index: 1 });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Tenta encontrar o select de tipo de atendimento especificamente
|
||||
const selectTipo = page.locator('select[id*="tipo_solicitacao"], select[id*="tipoAtendimento"], select:has-text("Tratamento")').first();
|
||||
await selectTipo.waitFor({ state: 'visible', timeout: 5000 }).catch(() => console.log('Aviso: Select de tipo não visível ainda.'));
|
||||
|
||||
const options = await selectTipo.evaluate(sel => Array.from(sel.options).map(o => o.text)).catch(() => []);
|
||||
console.log('Opções no select de tipo:', options);
|
||||
|
||||
try {
|
||||
await selectTipo.selectOption({ label: /Tratamento Odontológico/i });
|
||||
} catch (e) {
|
||||
console.log('Tentando selecionar pelo índice 1...');
|
||||
await selectTipo.selectOption({ index: 1 });
|
||||
}
|
||||
|
||||
// 8 e 9. Lupa Profissional Executante
|
||||
console.log('8. Selecionando Profissional Executante...');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Agora temos o ID exato do botão!
|
||||
const btnBuscaProf = page.locator('#btnBuscaPrestadorExecutante').first();
|
||||
|
||||
await btnBuscaProf.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btnBuscaProf.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Clica no profissional na tabela
|
||||
console.log('Selecionando o primeiro profissional da lista...');
|
||||
const linkProf = page.locator('table tbody tr td a').first();
|
||||
if (await linkProf.count() > 0) {
|
||||
await linkProf.click();
|
||||
} else {
|
||||
console.log('⚠️ Nenhum profissional encontrado na tabela. Aguardando instrução.');
|
||||
return; // PARA e aguarda
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 10. Profissional Solicitante
|
||||
console.log('10. Preenchendo Solicitante: Murilo...');
|
||||
const solicitanteInput = page.locator('input[id*="solicitante"], input[name*="Solicitante"]').first();
|
||||
if (await solicitanteInput.count() === 0) {
|
||||
console.log('⚠️ Campo "Profissional Solicitante" não encontrado. Aguardando instrução.');
|
||||
return;
|
||||
}
|
||||
await solicitanteInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await solicitanteInput.clear();
|
||||
await solicitanteInput.fill('Murilo');
|
||||
|
||||
// 11. Justificativa (APENAS se for urgência - Valor 4)
|
||||
const tipoAtendimentoValue = await page.locator('#tipoAtendimento').inputValue();
|
||||
if (tipoAtendimentoValue === '4') {
|
||||
console.log('Detectado Urgência / Emergência. Preenchendo Justificativa...');
|
||||
await page.fill('#observacaoJustificativa', 'Paciente com dor aguda, necessidade de atendimento de urgência.');
|
||||
} else {
|
||||
console.log('Tipo de atendimento normal. Pulando justificativa.');
|
||||
}
|
||||
|
||||
// 12. Próximo
|
||||
console.log('12. Clicando em Próximo...');
|
||||
await page.click('button:has-text("Próximo"), #btnProximo');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 13. Incluir
|
||||
console.log('13. Clicando em Incluir...');
|
||||
await page.click('#adicionarProcedimeto');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// EXTRAÇÃO DE CÓDIGOS (Como solicitado pelo usuário na primeira vez)
|
||||
console.log('🔍 Extraindo lista de procedimentos disponíveis...');
|
||||
const procedimentos = await page.evaluate(() => {
|
||||
const items = Array.from(document.querySelectorAll('input[id*="procedimento"], select[id*="procedimento"], .lista-procedimentos td'));
|
||||
// Tenta pegar de um select se houver
|
||||
const sel = document.querySelector('select[id*="procedimento"]');
|
||||
if (sel) return Array.from(sel.options).map(o => `${o.value} - ${o.text}`);
|
||||
return items.map(i => i.innerText || i.value).filter(t => t && t.length > 5);
|
||||
});
|
||||
|
||||
if (procedimentos.length > 0) {
|
||||
fs.writeFileSync('procedimentos_encontrados.txt', procedimentos.join('\n'));
|
||||
console.log('✅ Lista de procedimentos salva em procedimentos_encontrados.txt');
|
||||
}
|
||||
|
||||
// 15, 16 e 17. Dados do Tratamento
|
||||
console.log('15. Lançando procedimento 85100196...');
|
||||
const procInput = page.locator('#codProcedimento').first();
|
||||
await procInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await procInput.fill('85100196');
|
||||
|
||||
// Espera e clica no item do autocomplete
|
||||
console.log('Selecionando item no autocomplete...');
|
||||
const autocompleteItem = page.locator('.ui-menu-item-wrapper:has-text("85100196")').first();
|
||||
await autocompleteItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await autocompleteItem.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('16. Selecionando Dente 18...');
|
||||
const selectDente = page.locator('#cmbDente').first();
|
||||
await selectDente.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await selectDente.selectOption('18');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Selecionar a checkbox da Face "V"
|
||||
console.log('17. Selecionando Face V...');
|
||||
const checkV = page.locator('#codFaceV').first();
|
||||
await checkV.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Tenta clicar forçado no input ou clica na label (que é o que o usuário vê)
|
||||
try {
|
||||
await checkV.check({ force: true });
|
||||
} catch (e) {
|
||||
await page.click('label[for="codFaceV"]');
|
||||
}
|
||||
|
||||
// 18. Incluir procedimento (Botão Incluir dentro do modal)
|
||||
console.log('18. Incluindo procedimento na lista...');
|
||||
await page.click('#btnIncluirProcedimento');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Se houver um botão de fechar/salvar o popup geral (como o btnSalvarPopUP que vimos antes)
|
||||
const btnFechar = page.locator('#btnSalvarPopUP').first();
|
||||
if (await btnFechar.isVisible()) {
|
||||
console.log('Fechando popup de procedimentos...');
|
||||
await btnFechar.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 19. Finalizar
|
||||
console.log('19. Finalizando guia...');
|
||||
await page.click('button:has-text("Próximo")');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.click('button:has-text("Próximo"), #btnFinalizar');
|
||||
|
||||
// 20. Captura de GTO
|
||||
console.log('20. Capturando resultado da GTO...');
|
||||
await page.waitForTimeout(3000);
|
||||
const resultadoHTML = await page.content();
|
||||
const gtoMatch = resultadoHTML.match(/\d{8,12}/); // Tenta achar um número longo que seria a GTO
|
||||
const status = resultadoHTML.includes('Autorizado') ? 'AUTORIZADO' : 'VERIFICAR STATUS';
|
||||
|
||||
console.log(`--- RESULTADO --- GTO: ${gtoMatch ? gtoMatch[0] : 'Não localizada'} | STATUS: ${status}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na automação:', error.message);
|
||||
await page.screenshot({ path: 'erro-guia-odonto.png' });
|
||||
} finally {
|
||||
console.log('Processo interrompido para análise. O navegador ficará aberto por 60s.');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
// Configuração para evitar mensagem de navegador incompatível
|
||||
const browser = await chromium.launch({
|
||||
headless: false // Vamos deixar visível para você ver acontecendo
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Navegando para o portal...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
|
||||
console.log('Preenchendo credenciais...');
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
|
||||
console.log('Clicando em ENTRAR...');
|
||||
await page.click('#login-submit');
|
||||
|
||||
// Espera um pouco para ver o resultado (sucesso ou erro)
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log('URL atual após login:', currentUrl);
|
||||
|
||||
if (currentUrl.includes('AreaLogada')) {
|
||||
console.log('✅ LOGIN REALIZADO COM SUCESSO!');
|
||||
} else {
|
||||
// Tenta verificar erro apenas se não estiver na área logada
|
||||
const errorMsg = await page.locator('.msg:visible').textContent({ timeout: 5000 }).catch(() => null);
|
||||
if (errorMsg) {
|
||||
console.log('❌ Falha no login. Mensagem do sistema:', errorMsg.trim());
|
||||
} else {
|
||||
console.log('Status do login incerto. Verifique a janela do navegador.');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ocorreu um erro durante a automação:', error);
|
||||
} finally {
|
||||
// Mantemos o navegador aberto por 20 segundos para você ver o resultado
|
||||
console.log('Mantendo o navegador aberto por 20 segundos...');
|
||||
await page.waitForTimeout(20000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login OK. Varrendo todos os links e data-href...');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const links = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim(),
|
||||
href: a.getAttribute('href'),
|
||||
dataHref: a.getAttribute('data-href'),
|
||||
id: a.id
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('LISTA COMPLETA DE LINKS:');
|
||||
links.forEach(l => {
|
||||
if (l.text || l.dataHref) {
|
||||
console.log(`- TEXTO: "${l.text}" | HREF: "${l.href}" | DATA-HREF: "${l.dataHref}" | ID: "${l.id}"`);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro:', error);
|
||||
} finally {
|
||||
console.log('Fim da exploração.');
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
console.log('🔍 Iniciando mapeamento de API para Axios...');
|
||||
const browser = await chromium.launch({ headless: false }); // Usar Chrome visível para evitar bloqueio
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Interceptar todas as requisições para descobrir os Endpoints
|
||||
page.on('request', request => {
|
||||
if (request.method() === 'POST' && !request.url().includes('google-analytics')) {
|
||||
console.log(`\n🚀 POST Detectado: ${request.url()}`);
|
||||
console.log('Headers:', JSON.stringify(request.headers(), null, 2));
|
||||
console.log('Payload:', request.postData());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'load', timeout: 60000 });
|
||||
|
||||
console.log('Esperando formulário de login...');
|
||||
const userField = page.locator('#usuario, #Username').first();
|
||||
await userField.waitFor({ timeout: 30000 });
|
||||
|
||||
console.log('Preenchendo credenciais...');
|
||||
await userField.fill('0005010');
|
||||
const passField = page.locator('#senha, #Password').first();
|
||||
await passField.fill('Cclinic#03');
|
||||
|
||||
console.log('Clicando em Login...');
|
||||
const loginBtn = page.locator('#login-submit, #btnLogin').first();
|
||||
await loginBtn.click();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Navegando pelo menu...');
|
||||
await page.click('text="Tratamento Odontológico"');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Abrindo busca de beneficiário...');
|
||||
const searchBtn = page.locator('.fa-search, .glyphicon-search, button[title*="Pesquisar"]').first();
|
||||
await searchBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Inserindo CPF...');
|
||||
const cpfInput = page.locator('input[id*="cpf"], input[name*="cpf"], .cpf-mask').first();
|
||||
await cpfInput.fill('005.983.051-49');
|
||||
await page.click('button:has-text("Pesquisar"), #btnPesquisarBeneficiario');
|
||||
|
||||
console.log('Aguardando resultados da busca...');
|
||||
const linkBeneficiario = page.locator('table tbody tr td a').first();
|
||||
try {
|
||||
await linkBeneficiario.waitFor({ state: 'visible', timeout: 15000 });
|
||||
console.log('Selecionando beneficiário...');
|
||||
await linkBeneficiario.click();
|
||||
} catch (e) {
|
||||
console.log('⚠️ Resultado da busca não apareceu ou demorou demais.');
|
||||
const content = await page.content();
|
||||
if (content.includes('nenhum registro encontrado') || content.includes('não encontrado')) {
|
||||
console.log('❌ Beneficiário não encontrado no portal.');
|
||||
} else {
|
||||
console.log('Logando HTML para análise de erro...');
|
||||
require('fs').writeFileSync('logs-and-data/debug-search-error.html', content);
|
||||
}
|
||||
throw new Error('Falha na busca de beneficiário durante o mapeamento.');
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Clicando em Adicionar (+)...');
|
||||
const btnMais = page.locator('.fa-plus, .btn-success, #btnNovoRegistro, button:has-text("+")').first();
|
||||
await btnMais.click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Preenchendo dados da guia...');
|
||||
const selectTipo = page.locator('select[id*="tipoAtendimento"]').first();
|
||||
await selectTipo.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await selectTipo.selectOption('1');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Buscando Profissional Executante...');
|
||||
const btnBuscaProf = page.locator('#btnBuscaPrestadorExecutante').first();
|
||||
await btnBuscaProf.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Selecionando primeiro profissional na tabela...');
|
||||
const linkProf = page.locator('table tbody tr td a').first();
|
||||
await linkProf.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await linkProf.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Verificando campo Solicitante...');
|
||||
const solicitanteInput = page.locator('input[id*="solicitante"], input[name*="Solicitante"]').first();
|
||||
|
||||
// Tenta esperar o campo, mas loga se falhar
|
||||
try {
|
||||
await solicitanteInput.waitFor({ state: 'visible', timeout: 15000 });
|
||||
console.log('Preenchendo Solicitante...');
|
||||
await solicitanteInput.fill('Murilo');
|
||||
} catch (e) {
|
||||
console.log('⚠️ Campo Solicitante não apareceu. Continuando para tentar capturar outros POSTs...');
|
||||
}
|
||||
|
||||
console.log('Avançando para procedimentos...');
|
||||
const btnProx = page.locator('button:has-text("Próximo"), #btnProximo').first();
|
||||
await btnProx.click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Abrindo inclusão de procedimento...');
|
||||
await page.click('#adicionarProcedimeto');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Lançando procedimento para capturar JSON...');
|
||||
await page.fill('#codProcedimento', '85100196');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('.ui-menu-item-wrapper:has-text("85100196")');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.selectOption('#cmbDente', '11');
|
||||
await page.click('label[for="codFaceV"]');
|
||||
|
||||
console.log('Incluindo no modal (Captura JSON parcial)...');
|
||||
await page.click('#btnIncluirProcedimento');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('FINALIZANDO GUIA (Captura do POST Final)...');
|
||||
await page.click('button:has-text("Próximo")');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('button:has-text("Próximo"), #btnFinalizar');
|
||||
|
||||
await page.waitForTimeout(10000);
|
||||
console.log('\n✅ Mapeamento concluído com sucesso.');
|
||||
} catch (error) {
|
||||
console.error('Erro no mapeamento:', error);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
# BACKEND CONFIG
|
||||
PORT=3002
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=sis_odonto
|
||||
|
||||
# FRONTEND CONFIG (For local dev, usually not needed if using proxy/relative)
|
||||
# VITE_API_URL=http://localhost:3002/api
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,22 @@
|
||||
<IfModule mod_mime.c>
|
||||
# Força o servidor a enviar arquivos TSX/TS como texto javascript
|
||||
# Isso corrige o erro "MIME type of application/octet-stream"
|
||||
AddType text/javascript .js
|
||||
AddType text/javascript .jsx
|
||||
AddType text/javascript .ts
|
||||
AddType text/javascript .tsx
|
||||
AddType text/css .css
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /odonto/
|
||||
|
||||
# Se o arquivo ou diretório existir, serve ele normalmente
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Caso contrário, redireciona tudo para index.html (Fallback para SPA)
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HybridBackend } from './services/backend.ts';
|
||||
|
||||
import { Sidebar } from './components/Sidebar.tsx';
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
import { LeadsView } from './views/LeadsView.tsx';
|
||||
import { PublicContactForm } from './views/PublicContactForm.tsx';
|
||||
import { PatientsView } from './views/PatientsView.tsx';
|
||||
import { AgendaView } from './views/AgendaView.tsx';
|
||||
import { OrthoView } from './views/OrthoView.tsx';
|
||||
import { FinanceiroView } from './views/FinanceiroView.tsx';
|
||||
import { MeusTratamentos } from './views/MeusTratamentos';
|
||||
import { LancarGTO } from './views/LancarGTO';
|
||||
import { LoginView } from './views/LoginView.tsx';
|
||||
import { LandingPage } from './views/LandingPage.tsx';
|
||||
import { UpdateDbView } from './views/UpdateDbView.tsx';
|
||||
import { ClinicasView } from './views/ClinicasView.tsx';
|
||||
import { DentistRegisterView } from './views/DentistRegisterView.tsx';
|
||||
import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosView } from './views/AdminViews.tsx';
|
||||
import { NotificationsView } from './views/NotificationsView.tsx';
|
||||
import { ReportsView } from './views/ReportsView.tsx';
|
||||
|
||||
export type ViewKey =
|
||||
| 'landing'
|
||||
| 'dashboard'
|
||||
| 'leads'
|
||||
| 'public'
|
||||
| 'pacientes'
|
||||
| 'agenda'
|
||||
| 'ortodontia'
|
||||
| 'financeiro'
|
||||
| 'login'
|
||||
| 'update'
|
||||
| 'dentistas'
|
||||
| 'especialidades'
|
||||
| 'procedimentos'
|
||||
| 'planos'
|
||||
| 'tratamentos'
|
||||
| 'lancar-gto'
|
||||
| 'notificacoes'
|
||||
| 'clinicas'
|
||||
| 'sync'
|
||||
| 'relatorios'
|
||||
| 'cadastro-dentista';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/': 'landing',
|
||||
'/dashboard': 'dashboard',
|
||||
'/landing': 'landing',
|
||||
'/leads': 'leads',
|
||||
'/contato': 'public',
|
||||
'/pacientes': 'pacientes',
|
||||
'/agenda': 'agenda',
|
||||
'/ortodontia': 'ortodontia',
|
||||
'/financeiro': 'financeiro',
|
||||
'/login': 'login',
|
||||
'/update': 'update',
|
||||
'/clinicas': 'clinicas',
|
||||
'/admin/dentistas': 'dentistas',
|
||||
'/admin/especialidades': 'especialidades',
|
||||
'/admin/procedimentos': 'procedimentos',
|
||||
'/admin/planos': 'planos',
|
||||
'/admin/sync': 'sync',
|
||||
'/tratamentos': 'tratamentos',
|
||||
'/lancar-gto': 'lancar-gto',
|
||||
'/notificacoes': 'notificacoes',
|
||||
'/relatorios': 'relatorios',
|
||||
'/cadastro-dentista': 'cadastro-dentista',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
landing: '/',
|
||||
dashboard: '/dashboard',
|
||||
leads: '/leads',
|
||||
public: '/contato',
|
||||
pacientes: '/pacientes',
|
||||
agenda: '/agenda',
|
||||
ortodontia: '/ortodontia',
|
||||
financeiro: '/financeiro',
|
||||
login: '/login',
|
||||
update: '/update',
|
||||
clinicas: '/clinicas',
|
||||
dentistas: '/admin/dentistas',
|
||||
especialidades: '/admin/especialidades',
|
||||
procedimentos: '/admin/procedimentos',
|
||||
planos: '/admin/planos',
|
||||
tratamentos: '/tratamentos',
|
||||
'lancar-gto': '/lancar-gto',
|
||||
notificacoes: '/notificacoes',
|
||||
relatorios: '/relatorios',
|
||||
sync: '/admin/sync',
|
||||
'cadastro-dentista': '/cadastro-dentista'
|
||||
};
|
||||
|
||||
/** Get the hash path, e.g. "/#/pacientes" → "/pacientes" */
|
||||
function getHashPath(): string {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/')) return hash.slice(1); // "#/pacientes" → "/pacientes"
|
||||
if (hash === '#' || hash === '') return '/';
|
||||
return '/';
|
||||
}
|
||||
|
||||
/** Resolve current URL to a ViewKey */
|
||||
function resolveViewFromUrl(): ViewKey {
|
||||
// Legacy support: ?p=contato
|
||||
if (window.location.search.includes('p=contato')) return 'public';
|
||||
// Legacy support: #update_db (old non-slash hash)
|
||||
if (window.location.hash === '#update_db') return 'update';
|
||||
|
||||
const path = getHashPath();
|
||||
return ROUTE_MAP[path] ?? 'dashboard';
|
||||
}
|
||||
|
||||
/** Push a new hash-based URL without reloading the page */
|
||||
function navigate(view: ViewKey) {
|
||||
const route = VIEW_TO_ROUTE[view];
|
||||
window.location.hash = route;
|
||||
}
|
||||
|
||||
// ------- App -------
|
||||
const App: React.FC = () => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const clinColor = activeWorkspace?.cor || '#2563eb';
|
||||
|
||||
const isViewAllowed = (view: ViewKey, role: string): boolean => {
|
||||
if (['landing', 'login', 'public', 'update'].includes(view)) return true;
|
||||
if (role === 'admin' || role === 'donoclinica') return true;
|
||||
|
||||
const permissions: Record<string, ViewKey[]> = {
|
||||
paciente: ['tratamentos', 'notificacoes'],
|
||||
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas'],
|
||||
};
|
||||
|
||||
return permissions[role]?.includes(view) || false;
|
||||
};
|
||||
|
||||
const getDefaultViewForRole = (role: string): ViewKey => {
|
||||
if (role === 'paciente') return 'tratamentos';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const getInitialView = (): ViewKey => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
|
||||
// If user is authenticated, and trying to go to landing or login, send to default for role
|
||||
if (HybridBackend.isAuthenticated() && ['landing', 'login'].includes(fromUrl)) return getDefaultViewForRole(role);
|
||||
|
||||
// If URL points to a protected view but user is not auth'd → landing (or login)
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) return 'landing';
|
||||
|
||||
// Verify permission for the view
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
return getDefaultViewForRole(role);
|
||||
}
|
||||
|
||||
return fromUrl;
|
||||
};
|
||||
|
||||
const [currentView, setCurrentView] = useState<ViewKey>(getInitialView);
|
||||
|
||||
// Keep URL in sync when view changes programmatically via sidebar
|
||||
const handleNavigate = (view: ViewKey) => {
|
||||
if (isViewAllowed(view, currentRole)) {
|
||||
setCurrentView(view);
|
||||
navigate(view);
|
||||
setSidebarOpen(false); // Close sidebar on mobile after navigation
|
||||
}
|
||||
};
|
||||
|
||||
// Sync view when user uses browser back/forward buttons
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) {
|
||||
setCurrentView('landing');
|
||||
navigate('landing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
const defView = getDefaultViewForRole(role);
|
||||
setCurrentView(defView);
|
||||
navigate(defView);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentView(fromUrl);
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
// Inject dynamic brand color
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--brand-color', clinColor);
|
||||
document.documentElement.style.setProperty('--brand-color-soft', `${clinColor}11`);
|
||||
}, [clinColor]);
|
||||
|
||||
// On mount: if there's no hash yet, write the current view into the URL
|
||||
useEffect(() => {
|
||||
if (!window.location.hash || window.location.hash === '#update_db') {
|
||||
navigate(currentView);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'landing': return <LandingPage onGetStarted={() => handleNavigate('login')} />;
|
||||
case 'dashboard': return <Dashboard />;
|
||||
case 'leads': return <LeadsView />;
|
||||
case 'public': return <PublicContactForm />;
|
||||
case 'pacientes': return <PatientsView />;
|
||||
case 'agenda': return <AgendaView />;
|
||||
case 'ortodontia': return <OrthoView />;
|
||||
case 'financeiro': return <FinanceiroView />;
|
||||
case 'dentistas': return <DentistasView />;
|
||||
case 'especialidades': return <EspecialidadesView />;
|
||||
case 'procedimentos': return <ProcedimentosView />;
|
||||
case 'planos': return <PlanosView />;
|
||||
case 'sync': return <SyncView />;
|
||||
case 'tratamentos': return <MeusTratamentos />;
|
||||
case 'lancar-gto': return <LancarGTO />;
|
||||
case 'update': return <UpdateDbView />;
|
||||
case 'clinicas': return <ClinicasView />;
|
||||
case 'notificacoes': return <NotificationsView />;
|
||||
case 'relatorios': return <ReportsView />;
|
||||
case 'cadastro-dentista': return <DentistRegisterView />;
|
||||
case 'login':
|
||||
default:
|
||||
return <LoginView onLoginSuccess={() => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
handleNavigate(getDefaultViewForRole(role));
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista'].includes(currentView);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
|
||||
<style>{`
|
||||
:root { --brand-color: ${clinColor}; }
|
||||
.bg-brand { background-color: var(--brand-color); }
|
||||
.text-brand { color: var(--brand-color); }
|
||||
.border-brand { border-color: var(--brand-color); }
|
||||
.hover\\:bg-brand:hover { background-color: var(--brand-color); }
|
||||
.focus\\:ring-brand:focus { --tw-ring-color: var(--brand-color); }
|
||||
`}</style>
|
||||
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Hamburger Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 md:hidden backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Sidebar with mobile toggle logic */}
|
||||
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<Sidebar
|
||||
activeTab={currentView}
|
||||
setActiveTab={(t) => handleNavigate(t as ViewKey)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Navbar Header */}
|
||||
<div className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm shadow-sm" style={{ backgroundColor: clinColor }}>
|
||||
S
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
|
||||
</div>
|
||||
<div className="w-8"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 pb-20 scroll-smooth">
|
||||
<div className="max-w-7xl mx-auto h-full">
|
||||
{renderView()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1R1UvfhQooJcPo447sPMR5YdggjdMblPP
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@@ -0,0 +1,42 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkData() {
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
console.log('Connected to MySQL');
|
||||
|
||||
const [dentists] = await connection.query('SELECT * FROM dentistas');
|
||||
console.log('\n--- DENTISTAS ---');
|
||||
console.table(dentists.map(d => ({ id: d.id, nome: d.nome })));
|
||||
|
||||
const [agendamentos] = await connection.query('SELECT * FROM agendamentos');
|
||||
console.log('\n--- AGENDAMENTOS (Counts per Dentist) ---');
|
||||
const counts = {};
|
||||
agendamentos.forEach(a => {
|
||||
counts[a.dentistaId] = (counts[a.dentistaId] || 0) + 1;
|
||||
});
|
||||
|
||||
const countsTable = Object.entries(counts).map(([id, count]) => {
|
||||
const d = dentists.find(dentist => dentist.id === id);
|
||||
return {
|
||||
dentistId: id,
|
||||
nome: d ? d.nome : 'UNKNOWN (Maybe deleted or not assigned)',
|
||||
count: count
|
||||
};
|
||||
});
|
||||
console.table(countsTable);
|
||||
|
||||
await connection.end();
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
checkData();
|
||||
@@ -0,0 +1,25 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkData() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [dentists] = await connection.query('SELECT * FROM dentistas');
|
||||
const [agendamentos] = await connection.query('SELECT * FROM agendamentos');
|
||||
console.log('\n--- DENTISTAS ---');
|
||||
console.table(dentists.map(d => ({ id: d.id, nome: d.nome })));
|
||||
console.log('\n--- AGENDAMENTOS ---');
|
||||
console.table(agendamentos.map(a => {
|
||||
const d = dentists.find(dent => dent.id === a.dentistaId);
|
||||
return {
|
||||
id: a.id,
|
||||
paciente: a.pacienteNome,
|
||||
dentista: d ? d.nome : 'UNKNOWN',
|
||||
dentistaId: a.dentistaId,
|
||||
startTime: a.start_time
|
||||
};
|
||||
}));
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkData();
|
||||
@@ -0,0 +1,12 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkDentists() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.query('SELECT * FROM dentistas');
|
||||
console.table(rows);
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkDentists();
|
||||
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkSettings() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.query('SELECT * FROM settings WHERE id = "main"');
|
||||
if (rows[0]) {
|
||||
console.log('\n--- SETTINGS ---');
|
||||
console.log(JSON.stringify(JSON.parse(rows[0].data), null, 2));
|
||||
} else {
|
||||
console.log('No settings found.');
|
||||
}
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkSettings();
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Settings, Dentista } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
|
||||
import { Link as LinkIcon, Copy, Share2 } from 'lucide-react';
|
||||
|
||||
interface AgendaSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
dentists: Dentista[] | null;
|
||||
}
|
||||
|
||||
export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen, onClose, dentists }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [config, setConfig] = useState<Settings>({
|
||||
clinicEmail: '',
|
||||
clinicCalendarId: '',
|
||||
googleApiKey: '',
|
||||
googleCalendarIds: {}
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await HybridBackend.getSettings();
|
||||
setConfig({
|
||||
clinicEmail: settings.clinicEmail || '',
|
||||
clinicCalendarId: settings.clinicCalendarId || '',
|
||||
googleApiKey: settings.googleApiKey || '',
|
||||
googleCalendarIds: settings.googleCalendarIds || {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar configurações:", error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
fetchGoogleStatus();
|
||||
setPage(1); // Reset to first page
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const copyInviteLink = (dentistId: string) => {
|
||||
const url = `http://localhost:3005/api/auth/google/url?dentistId=${dentistId}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("LINK DE CONVITE COPIADO!");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await HybridBackend.saveSettings(config);
|
||||
toast.success("CONFIGURAÇÕES SALVAS COM SUCESSO!");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error("ERRO AO SALVAR CONFIGURAÇÕES.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const totalPages = 3;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-4">
|
||||
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-300 flex flex-col h-[550px] border border-gray-100">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-10 py-8 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-600 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<GearIcon size={24} className="text-white animate-spin-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tighter">Configurações da Agenda</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Integrações e Comunicação</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1.5 w-full bg-gray-100 flex">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-500 ease-out shadow-[0_0_10px_rgba(37,99,235,0.5)]"
|
||||
style={{ width: `${(page / totalPages) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Horizontal Slider Simulation */}
|
||||
<div className="flex-1 relative overflow-hidden bg-white">
|
||||
<div
|
||||
className="absolute inset-0 flex transition-transform duration-500 ease-in-out transform-gpu"
|
||||
style={{ transform: `translateX(-${(page - 1) * 100}%)` }}
|
||||
>
|
||||
{/* Page 1: Clinic Emails */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Mail className="text-blue-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">E-mails da Clínica</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">E-mail para Notificações</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="EX: CONTATO@CLINICA.COM"
|
||||
className="w-full bg-gray-50 border-2 border-transparent focus:border-blue-600 focus:bg-white rounded-2xl p-4 font-bold text-gray-800 outline-none transition-all shadow-sm"
|
||||
value={config.clinicEmail}
|
||||
onChange={(e) => setConfig({ ...config, clinicEmail: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 2: Google Setup Tutorial & Central Config */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="text-emerald-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Configuração Google Agenda</h3>
|
||||
</div>
|
||||
<a href="https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-black px-3 py-1.5 rounded-lg hover:opacity-80 transition-all uppercase tracking-widest">
|
||||
1. Ativar API
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-5 h-5 bg-emerald-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">1</div>
|
||||
<span className="text-[10px] font-black text-emerald-800 uppercase">Google API Key</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="COLE A CHAVE AQUI"
|
||||
className="w-full bg-white border border-emerald-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-emerald-100"
|
||||
value={config.googleApiKey}
|
||||
onChange={(e) => setConfig({ ...config, googleApiKey: e.target.value })}
|
||||
/>
|
||||
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noreferrer" className="text-[8px] font-black text-emerald-600 hover:underline mt-2 inline-block uppercase">Criar Credenciais ➔</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-5 h-5 bg-blue-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">2</div>
|
||||
<span className="text-[10px] font-black text-blue-800 uppercase">ID Agenda Clínica</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID DA AGENDA"
|
||||
className="w-full bg-white border border-blue-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-100"
|
||||
value={config.clinicCalendarId}
|
||||
onChange={(e) => setConfig({ ...config, clinicCalendarId: e.target.value })}
|
||||
/>
|
||||
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[8px] font-black text-blue-600 hover:underline mt-2 inline-block uppercase">Pegar ID nas Configs ➔</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-3xl p-6 text-white">
|
||||
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-[0.2em] mb-4">Guia de Integração</h4>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">Crie um projeto no <strong>Google Cloud Console</strong>.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">No menu 'Biblioteca', ative a <strong>Google Calendar API</strong>.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-gray-300">Pegue o <strong>ID da Agenda</strong> nas configurações (Integrar agenda).</span>
|
||||
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-blue-400 hover:underline flex items-center gap-1 uppercase">
|
||||
Abrir Configurações de Agenda ➔
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">No Google Agenda, torne a agenda <strong>Pública</strong> nas autorizações de acesso.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 3: Google Calendar Mapping */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="text-emerald-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Agendas dos Dentistas</h3>
|
||||
</div>
|
||||
<a href="https://calendar.google.com/calendar/u/0/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-gray-900 px-3 py-1.5 rounded-lg hover:bg-black transition-colors uppercase tracking-widest">
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[250px] overflow-y-auto pr-4 custom-scrollbar">
|
||||
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 mb-2">
|
||||
<p className="text-[10px] text-blue-800 font-bold mb-1 uppercase tracking-wider">Nova Integração Inteligente (OAuth2)</p>
|
||||
<p className="text-[10px] text-blue-600 italic">Agora você pode conectar agendas sem precisar torná-las públicas ou usar API Keys.</p>
|
||||
</div>
|
||||
|
||||
{/* ADMIN / CLINIC CONNECTION */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Sua Conta (Administrador)</h4>
|
||||
<GoogleConnectButton
|
||||
ownerId="admin"
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id.includes('@'))}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Contas dos Dentistas</h4>
|
||||
{dentists?.length ? dentists.map((dentist) => {
|
||||
const isConnected = connectedAccounts.some(a => a.owner_id === dentist.id);
|
||||
return (
|
||||
<div key={dentist.id} className="bg-gray-50 rounded-2xl p-4 border border-transparent hover:border-gray-200 transition-all">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: dentist.corAgenda }}></div>
|
||||
<span className="text-xs font-black text-gray-800 uppercase">{dentist.nome}</span>
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<span className="text-[9px] font-black text-emerald-600 bg-emerald-100 px-2 py-1 rounded-lg uppercase">Conectado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black text-gray-400 uppercase">Não Vinculado</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<GoogleConnectButton
|
||||
ownerId={dentist.id}
|
||||
isConnected={isConnected}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<button
|
||||
onClick={() => copyInviteLink(dentist.id)}
|
||||
className="p-4 bg-gray-100 text-gray-600 rounded-2xl hover:bg-gray-200 transition-all flex items-center justify-center group"
|
||||
title="Gerar Link para o Dentista"
|
||||
>
|
||||
<Share2 size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legacy ID support remains for backward compatibility */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200/50">
|
||||
<label className="text-[9px] font-bold text-gray-400 uppercase ml-1">ID Alternativo (API Key/Legado)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID DA AGENDA GOOGLE (SE NÃO USAR OAUTH2)"
|
||||
className="w-full bg-white/50 border border-gray-200 rounded-xl px-4 py-2 text-[10px] font-bold text-gray-500 outline-none focus:ring-1 focus:ring-blue-100 mt-1"
|
||||
value={config.googleCalendarIds?.[dentist.id] || ''}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
googleCalendarIds: {
|
||||
...config.googleCalendarIds,
|
||||
[dentist.id]: e.target.value
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<p className="text-center text-xs text-gray-400 py-10 uppercase font-bold">Nenhum dentista cadastrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="flex items-center gap-2 px-6 py-3 text-xs font-black text-gray-500 hover:text-gray-900 bg-white rounded-2xl border border-gray-200 hover:shadow-md transition-all uppercase tracking-widest"
|
||||
>
|
||||
<ChevronLeft size={16} /> Anterior
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{page < totalPages ? (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="flex items-center gap-2 px-8 py-4 bg-blue-600 text-white rounded-[1.5rem] text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
||||
>
|
||||
Próximo <ChevronRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 px-10 py-4 bg-emerald-600 text-white rounded-[1.5rem] text-xs font-black uppercase tracking-widest hover:bg-emerald-700 hover:shadow-xl hover:shadow-emerald-200 transition-all shadow-lg shadow-emerald-100 disabled:bg-gray-400"
|
||||
>
|
||||
{isSaving ? 'Salvando...' : <><Save size={16} /> Salvar Tudo</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw, XCircle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
// FIX: Switched to constructor-based state initialization to resolve issues
|
||||
// where the TypeScript toolchain might not correctly interpret public class fields,
|
||||
// causing `this.props` and `this.setState` to appear unavailable. This is a more
|
||||
// widely compatible approach.
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-red-50 flex items-center justify-center p-6 font-sans">
|
||||
<div className="max-w-3xl w-full bg-white rounded-2xl shadow-2xl overflow-hidden border border-red-100">
|
||||
<div className="bg-red-600 p-6 flex items-center gap-4">
|
||||
<div className="bg-white/20 p-3 rounded-full text-white">
|
||||
<XCircle size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white uppercase">ERRO CRÍTICO NO SISTEMA</h1>
|
||||
<p className="text-red-100 text-xs font-bold uppercase mt-1">O APLICATIVO ENCONTROU UM PROBLEMA INESPERADO</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 font-bold uppercase mb-2">O QUE ACONTECEU?</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Ocorreu uma falha durante a renderização da interface. Tente recarregar a página.
|
||||
Se o problema persistir, envie o log abaixo para o suporte técnico.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="bg-gray-900 rounded-lg p-4 mb-6 overflow-x-auto border-l-4 border-red-500">
|
||||
<div className="flex items-center gap-2 text-red-400 font-bold text-xs uppercase mb-2">
|
||||
<AlertTriangle size={14} /> Stack Trace / Log Técnico
|
||||
</div>
|
||||
<pre className="text-green-400 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words">
|
||||
{this.state.error.toString()}
|
||||
<br />
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors shadow-lg"
|
||||
>
|
||||
<RefreshCw size={18} /> RECARREGAR SISTEMA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { localStorage.clear(); window.location.reload(); }}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors"
|
||||
>
|
||||
LIMPAR CACHE E REINICIAR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Link, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface GoogleConnectButtonProps {
|
||||
ownerId: string; // Ex: 'admin' ou o ID do dentista
|
||||
isConnected: boolean;
|
||||
onStatusChange?: () => void;
|
||||
}
|
||||
|
||||
export const GoogleConnectButton: React.FC<GoogleConnectButtonProps> = ({ ownerId, isConnected, onStatusChange }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3005/api/auth/google/url?dentistId=${ownerId}`);
|
||||
const { url } = await response.json();
|
||||
|
||||
// Abrir em popup para manter o contexto
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const popup = window.open(
|
||||
url,
|
||||
'google-auth',
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
|
||||
// Verificar se o popup fechou a cada 1s
|
||||
const checkPopup = setInterval(() => {
|
||||
if (!popup || popup.closed) {
|
||||
clearInterval(checkPopup);
|
||||
setIsLoading(false);
|
||||
if (onStatusChange) onStatusChange();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao conectar Google Agenda:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('Deseja realmente desconectar esta agenda?')) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetch(`http://localhost:3005/api/auth/google/${ownerId}`, { method: 'DELETE' });
|
||||
if (onStatusChange) onStatusChange();
|
||||
} catch (error) {
|
||||
console.error('Erro ao desconectar:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
|
||||
<div className="p-2 bg-emerald-500 rounded-xl">
|
||||
<CheckCircle2 size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] font-black text-emerald-800 uppercase tracking-tight">Google Agenda Conectada</p>
|
||||
<p className="text-[9px] text-emerald-600 font-bold uppercase tracking-widest mt-0.5">Sincronização Ativa</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
disabled={isLoading}
|
||||
className="text-[9px] font-black text-red-500 hover:text-red-700 uppercase tracking-widest px-3 py-1 bg-white border border-red-100 rounded-lg shadow-sm transition-all"
|
||||
>
|
||||
{isLoading ? <Loader2 size={12} className="animate-spin" /> : 'Desconectar'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isLoading}
|
||||
className="w-full group relative overflow-hidden flex items-center gap-4 bg-white border-2 border-gray-100 p-4 rounded-2xl hover:border-blue-500 hover:shadow-xl hover:shadow-blue-50/50 transition-all duration-300"
|
||||
>
|
||||
<div className="p-3 bg-gray-50 group-hover:bg-blue-600 rounded-xl transition-colors duration-300">
|
||||
<Calendar size={20} className="text-gray-400 group-hover:text-white transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xs font-black text-gray-800 uppercase tracking-tight group-hover:text-blue-600">Conectar Google Agenda</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Sincronize horários externos</p>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="absolute right-4">
|
||||
<Loader2 size={18} className="text-blue-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Building2, ChevronDown, User, LogOut } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const userName = HybridBackend.getCurrentUserName();
|
||||
const userEmail = HybridBackend.getCurrentUser();
|
||||
|
||||
const handleWorkspaceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
HybridBackend.switchWorkspace(e.target.value);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
|
||||
HybridBackend.logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-4 md:px-8 sticky top-0 z-20 shadow-sm">
|
||||
{/* Clinic Selector */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="hidden sm:flex w-10 h-10 bg-blue-50 rounded-xl items-center justify-center text-blue-600 shadow-inner">
|
||||
<Building2 size={20} />
|
||||
</div>
|
||||
<div className="relative w-full max-w-[200px] md:max-w-[300px]">
|
||||
<select
|
||||
value={activeWorkspace?.id || ''}
|
||||
onChange={handleWorkspaceChange}
|
||||
className="w-full bg-gray-50 border border-gray-200 text-gray-800 text-xs font-bold uppercase py-2 pl-3 pr-8 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all appearance-none cursor-pointer truncate"
|
||||
>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Actions */}
|
||||
<div className="flex items-center gap-2 md:gap-4 ml-4">
|
||||
<div className="hidden md:flex flex-col items-end mr-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase leading-none mb-1">Unidade Ativa</span>
|
||||
<span className="text-xs font-bold text-blue-600 uppercase border border-blue-100 bg-blue-50 px-2 py-0.5 rounded shadow-sm">
|
||||
{activeWorkspace?.role || 'Acesso'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-gray-100 hidden sm:block mx-1"></div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs font-bold text-gray-800 truncate max-w-[120px]" title={userName}>
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-[9px] text-gray-400 font-bold truncate max-w-[120px]" title={userEmail}>
|
||||
{userEmail}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-9 h-9 bg-gray-100 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer border border-white shadow-sm">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
|
||||
title="SAIR"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, X, Calendar, DollarSign, AlertCircle, UserCheck, MessageSquare, ShieldAlert } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Notificacao } from '../types.ts';
|
||||
|
||||
export const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notificacoes, setNotificacoes] = useState<Notificacao[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const data = await HybridBackend.getNotifications();
|
||||
setNotificacoes(data);
|
||||
setUnreadCount(data.filter(n => !n.lida).length);
|
||||
} catch (err) {
|
||||
console.error("Erro ao carregar notificações");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
const interval = setInterval(fetch, 30000); // Polling cada 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const toggleOpen = () => setIsOpen(!isOpen);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
await HybridBackend.markNotificationRead(id);
|
||||
setNotificacoes(prev => prev.map(n => n.id === id ? { ...n, lida: true } : n));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const getIcon = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'agenda': return <Calendar size={18} className="text-blue-500" />;
|
||||
case 'financeiro': return <DollarSign size={18} className="text-emerald-500" />;
|
||||
case 'lead': return <UserCheck size={18} className="text-amber-500" />;
|
||||
case 'sistema': return <ShieldAlert size={18} className="text-red-500" />;
|
||||
default: return <MessageSquare size={18} className="text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'agenda': return 'Agenda';
|
||||
case 'financeiro': return 'Financeiro';
|
||||
case 'lead': return 'Novo Lead';
|
||||
case 'sistema': return 'Sistema';
|
||||
default: return 'Geral';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-[1002]">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-xl bg-white/80 backdrop-blur-md border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.06)] hover:shadow-[0_8px_32px_rgba(59,130,246,0.15)] hover:border-blue-400/50 transition-all duration-300"
|
||||
>
|
||||
<Bell size={22} className="text-slate-600 group-hover:text-blue-600 transition-colors" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-5 w-5 bg-red-500 text-[10px] font-bold text-white items-center justify-center shadow-lg">
|
||||
{unreadCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0" onClick={toggleOpen} />
|
||||
<div className="absolute right-0 mt-4 w-[380px] bg-white/95 backdrop-blur-lg rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-white/20 overflow-hidden animate-in fade-in zoom-in-95 slide-in-from-top-4 duration-300 transform origin-top-right">
|
||||
<div className="px-6 py-5 bg-gradient-to-r from-slate-50 to-white border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 text-lg tracking-tight">Centro de Notificações</h3>
|
||||
<p className="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Você tem {unreadCount} novas mensagens</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<X size={18} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[450px] overflow-y-auto custom-scrollbar bg-slate-50/30">
|
||||
{notificacoes.length === 0 ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center text-center px-8">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Bell size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p className="text-slate-500 font-medium">Tudo limpo por aqui!</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Não há notificações para mostrar no momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{notificacoes.map(notif => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`group p-5 hover:bg-white transition-all duration-200 cursor-default ${!notif.lida ? 'bg-blue-50/40 relative' : ''}`}
|
||||
>
|
||||
{!notif.lida && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 rounded-r-full" />
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div className={`mt-1 h-10 w-10 flex-shrink-0 flex items-center justify-center rounded-xl ${notif.tipo === 'agenda' ? 'bg-blue-100 text-blue-600' :
|
||||
notif.tipo === 'financeiro' ? 'bg-emerald-100 text-emerald-600' :
|
||||
notif.tipo === 'lead' ? 'bg-amber-100 text-amber-600' :
|
||||
'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{getIcon(notif.tipo)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">
|
||||
{getTypeLabel(notif.tipo)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{new Date(notif.data).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className={`text-sm font-bold leading-tight ${!notif.lida ? 'text-slate-900' : 'text-slate-600'}`}>
|
||||
{notif.titulo}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">
|
||||
{notif.mensagem}
|
||||
</p>
|
||||
{!notif.lida && (
|
||||
<button
|
||||
onClick={() => markAsRead(notif.id)}
|
||||
className="text-[11px] text-blue-600 font-bold mt-3 opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
|
||||
>
|
||||
MARCAR COMO LIDA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white border-t border-slate-100 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.hash = '/notificacoes';
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-xs font-bold text-slate-500 hover:text-blue-600 transition-colors tracking-widest uppercase"
|
||||
>
|
||||
Histórico Completo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { NotificationCenter } from './NotificationCenter.tsx';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({ title, description, children }) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 uppercase">{title}</h2>
|
||||
{description && <p className="text-gray-500 text-xs uppercase font-bold mt-1">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users, Calendar as CalendarIcon, LayoutDashboard, Stethoscope,
|
||||
DollarSign, RefreshCw, Database, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3
|
||||
} from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (t: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const userName = HybridBackend.getCurrentUserName();
|
||||
const userEmail = HybridBackend.getCurrentUser();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const clinColor = activeWorkspace?.cor || '#2563eb';
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleStatus();
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'clinicas', label: 'MINHAS CLÍNICAS', icon: Building2 },
|
||||
{ id: 'leads', label: 'GESTÃO DE LEADS', icon: Megaphone },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
{ id: 'tratamentos', label: 'MEUS TRATAMENTOS', icon: ClipboardList },
|
||||
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
|
||||
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
|
||||
{ id: 'dentistas', label: 'DENTISTAS', icon: Stethoscope },
|
||||
{ id: 'especialidades', label: 'ESPECIALIDADES', icon: Award },
|
||||
{ id: 'procedimentos', label: 'PROCEDIMENTOS', icon: ClipboardList },
|
||||
{ id: 'planos', label: 'PLANOS / CONVÊNIOS', icon: BookOpen },
|
||||
{ id: 'notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
|
||||
{ id: 'sync', label: 'SINCRONIZAÇÃO', icon: RefreshCw },
|
||||
].filter(item => {
|
||||
if (['admin', 'donoclinica'].includes(currentRole)) return true;
|
||||
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes'].includes(item.id);
|
||||
if (currentRole === 'dentista') return ['dashboard', 'pacientes', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas'].includes(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
|
||||
HybridBackend.logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col shadow-xl md:shadow-none">
|
||||
<div className="p-6 flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shadow-lg transition-colors duration-500"
|
||||
style={{ backgroundColor: clinColor }}
|
||||
>
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-gray-800 block leading-tight tracking-tight">SCOREODONTO</span>
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">MYSQL + GOOGLE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-[11px] font-bold uppercase transition-all duration-300 group ${isActive
|
||||
? 'text-white'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? clinColor : 'transparent',
|
||||
boxShadow: isActive ? `0 10px 15px -5px ${clinColor}44` : 'none'
|
||||
}}
|
||||
>
|
||||
<Icon size={18} className={`${isActive ? 'text-white' : 'text-gray-400 group-hover:text-gray-600'} transition-colors`}
|
||||
style={{ color: isActive ? '#fff' : undefined }} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50/50 space-y-4">
|
||||
{/* User Profile Section */}
|
||||
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-gray-500 border border-gray-200">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-black text-gray-800 uppercase truncate leading-none mb-1">{userName}</p>
|
||||
<p className="text-[9px] font-bold text-gray-400 truncate">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 rounded-lg border transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: `${clinColor}08`,
|
||||
borderColor: `${clinColor}22`
|
||||
}}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shadow-sm" style={{ backgroundColor: clinColor }}></div>
|
||||
<span className="text-[9px] font-black uppercase truncate" style={{ color: clinColor }}>
|
||||
{activeWorkspace?.nome || 'Sem Unidade'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentRole === 'admin' && (
|
||||
<GoogleConnectButton
|
||||
ownerId="admin"
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id === userEmail)}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-red-500 hover:bg-red-50 rounded-xl text-[10px] font-black uppercase transition-all"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
SAIR DO SISTEMA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CheckCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="text-green-500" size={20} />,
|
||||
error: <AlertTriangle className="text-red-500" size={20} />,
|
||||
info: <Info className="text-blue-500" size={20} />,
|
||||
};
|
||||
|
||||
const toastColors = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
info: 'bg-blue-50 border-blue-200',
|
||||
};
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useToast();
|
||||
const portalElement = document.getElementById('toast-container');
|
||||
|
||||
if (!portalElement) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-6 right-6 z-[100] space-y-3">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`w-80 p-4 rounded-xl shadow-lg flex items-start gap-3 border animate-in slide-in-from-top-4 ${toastColors[toast.type]}`}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">{icons[toast.type]}</div>
|
||||
<p className="flex-1 text-sm text-gray-800 font-bold uppercase">{toast.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
portalElement
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: ToastMessage[];
|
||||
addToast: (message: string, type: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
};
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
};
|
||||
|
||||
// FIX: Modified useToast to return the full context including `toasts` array,
|
||||
// so that ToastContainer can access it. Kept helper functions for backward compatibility.
|
||||
export const useToast = (): ToastContextType & {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
} => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
success: (message: string) => context.addToast(message, 'success'),
|
||||
error: (message: string) => context.addToast(message, 'error'),
|
||||
info: (message: string) => context.addToast(message, 'info'),
|
||||
};
|
||||
};
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Fetcher<T> = (...args: any[]) => Promise<T>;
|
||||
|
||||
interface UseHybridBackendReturn<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useHybridBackend<T>(
|
||||
fetcher: Fetcher<T>,
|
||||
deps: any[] = []
|
||||
): UseHybridBackendReturn<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [refreshCount, setRefreshCount] = useState(0);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('An unknown error occurred'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, refreshCount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ScoreOdonto Base Styles */
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.fc {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.fc-toolbar-title {
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background-color: #2563eb !important;
|
||||
border-color: #2563eb !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-event {
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uppercase-input,
|
||||
.uppercase-textarea,
|
||||
.uppercase-select {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dark-date-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* FullCalendar Display Fixes */
|
||||
.fc {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
.fc-timegrid-slot {
|
||||
height: 3em !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell {
|
||||
background-color: #f9fafb;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.fc-scrollgrid {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ScoreOdonto CRM</title>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf('/odonto') !== -1 && !path.endsWith('/') && path.split('/').pop().indexOf('.') === -1) {
|
||||
var newUrl = path + '/' + window.location.search + window.location.hash;
|
||||
window.location.replace(newUrl);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<base href="./">
|
||||
|
||||
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="toast-container"></div>
|
||||
<div id="global-error-display"
|
||||
style="display: none; position: fixed; top: 10px; left: 10px; right: 10px; background-color: #ef4444; color: white; padding: 16px; border-radius: 8px; z-index: 9999; font-family: monospace; font-size: 14px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
||||
import { ToastProvider } from './contexts/ToastContext.tsx';
|
||||
import './index.css';
|
||||
|
||||
// CSS imports are now handled in index.html via <link> tags for AI Studio compatibility,
|
||||
// but we add it here too for Vite dev server robustness.
|
||||
|
||||
/**
|
||||
* AI Studio Compatible Entrypoint
|
||||
*
|
||||
* This version restores the necessary providers (`ErrorBoundary`, `ToastProvider`)
|
||||
* to allow the application to run correctly after the initial bootstrap.
|
||||
* It uses the modern `createRoot` API, which is required for React 18 and newer.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
} else {
|
||||
console.error("CRITICAL: Root element #root not found. App could not be mounted.");
|
||||
const errorDiv = document.getElementById('global-error-display');
|
||||
if (errorDiv) {
|
||||
errorDiv.style.display = 'block';
|
||||
errorDiv.innerText = 'ERRO CRÍTICO: O ELEMENTO #ROOT NÃO FOI ENCONTRADO NO HTML.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Shield, Activity, Heart, Check, ArrowRight, Star, Clock, Users, Smartphone, Zap } from 'lucide-react';
|
||||
|
||||
export const MedLandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white font-sans selection:bg-cyan-500 selection:text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-slate-900/50 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tighter uppercase italic">CRM MÉDICO <span className="text-cyan-400">FIDELIDADE</span></span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-bold uppercase tracking-widest text-slate-400">
|
||||
<a href="#planos" className="hover:text-white transition-colors">Planos</a>
|
||||
<a href="#beneficios" className="hover:text-white transition-colors">Benefícios</a>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="hover:text-white transition-colors uppercase"
|
||||
>
|
||||
Acesso Restrito
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="px-6 py-2.5 bg-white text-slate-900 rounded-full hover:bg-cyan-400 transition-all hover:-translate-y-1"
|
||||
>
|
||||
Voltar ao CRM Odonto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-40 pb-20 overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-cyan-500/10 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-blue-600/10 rounded-full blur-[120px] animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-full text-xs font-black uppercase tracking-widest text-cyan-400 mb-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Zap size={14} /> Novo: Fidelidade Multimédica
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-black tracking-tighter leading-[0.9] text-white mb-8 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||
SAÚDE TOTAL, <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600">SEM COMPROMISSO.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 max-w-xl leading-relaxed mb-12 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200">
|
||||
O primeiro plano de fidelidade que integra medicina, odontologia e bem-estar em um único ecossistema. Fidelize sua saúde com tecnologia de ponta.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 animate-in fade-in slide-in-from-bottom-10 duration-1000 delay-500">
|
||||
<button className="px-10 py-5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-2xl font-black uppercase tracking-widest hover:scale-105 transition-all shadow-xl shadow-cyan-500/20 flex items-center gap-3">
|
||||
COMEÇAR AGORA <ArrowRight size={20} />
|
||||
</button>
|
||||
<button className="px-10 py-5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl font-black uppercase tracking-widest transition-all">
|
||||
VER PLANOS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
<section id="planos" className="py-32 bg-slate-900/40 relative">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">ESTRUTURA DE <span className="text-cyan-400">PLANOS</span></h2>
|
||||
<p className="text-slate-400 uppercase tracking-[0.3em] font-bold text-xs">Exclusivo: Combos que não aceitam desmembramento</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Base Plan */}
|
||||
<div className="group relative bg-white/5 border border-white/10 rounded-[3rem] p-12 hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute top-8 right-8 text-slate-700 font-black text-6xl group-hover:text-slate-600 transition-colors">01</div>
|
||||
<div className="mb-10">
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA BASE (OBRIGATÓRIO)</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + <br />ODONTO</h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Consultas Médicas ilimitadas',
|
||||
'Limpeza e Prevenção Dental',
|
||||
'Urgências 24h',
|
||||
'Rede Credenciada Platinum',
|
||||
'Descontos em Medicamentos'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 149</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all">
|
||||
SELECIONAR PLANO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Premium Plan */}
|
||||
<div className="group relative bg-white/5 border-2 border-cyan-500/50 rounded-[3rem] p-12 overflow-hidden hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="absolute top-8 right-8 text-cyan-900/30 font-black text-6xl group-hover:text-cyan-900/50 transition-colors">02</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-4 py-1.5 bg-gradient-to-r from-amber-400 to-orange-500 text-black text-[10px] font-black uppercase tracking-widest rounded-full">MAIS POPULAR</span>
|
||||
</div>
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA PREMIUM</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + ODONTO <br /><span className="text-cyan-400">+ PSICOLOGIA</span></h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Tudo da Linha Base',
|
||||
'Suporte Psicológico 24h',
|
||||
'Sessões de Terapia Mensais',
|
||||
'Check-up Executivo Anual',
|
||||
'Prioridade na Agenda (VIP)'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500 flex items-center justify-center text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 249</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-gradient-to-r from-cyan-500 to-blue-600 text-white rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] transition-all shadow-lg shadow-cyan-500/20">
|
||||
ASSINAR PREMIUM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features (CRM) */}
|
||||
<section id="beneficios" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ icon: Users, title: 'TITULAR E DEP.', desc: 'Gestão completa familiar com carteirinha individual digital.' },
|
||||
{ icon: Shield, title: 'ESTABILIDADE', desc: 'Previsibilidade total para o médico e segurança para o paciente.' },
|
||||
{ icon: Smartphone, title: 'WALLET DIGITAL', desc: 'QR Code e histórico médico sempre no seu bolso.' },
|
||||
{ icon: Zap, title: 'SEM GLOSA', desc: 'Processamento automático e repasse médico instantâneo.' }
|
||||
].map((feat, i) => (
|
||||
<div key={i} className="p-8 bg-white/2 hover:bg-white/5 border border-white/5 rounded-3xl transition-all">
|
||||
<feat.icon className="text-cyan-400 mb-6" size={32} />
|
||||
<h4 className="text-lg font-black tracking-widest uppercase mb-4">{feat.title}</h4>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{feat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-white/5 bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
||||
<Activity className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="font-black uppercase tracking-tighter">CRM MÉDICO</span>
|
||||
</div>
|
||||
<div className="text-slate-500 text-[10px] font-black uppercase tracking-[0.4em]">
|
||||
© 2026 SCOREODONTO HYBRID MEDICAL ENGINE. ALL RIGHTS RESERVED.
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Sair para Odonto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Painel Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-in {
|
||||
animation: animate-in 0.8s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users, Calendar, DollarSign, Activity,
|
||||
ArrowUpRight, ArrowDownRight, Zap, Target,
|
||||
TrendingUp, Clock, AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
export const MedDashboard: React.FC = () => {
|
||||
const stats = [
|
||||
{ label: 'Pacientes Ativos', value: '1.284', change: '+12%', positive: true, icon: Users, color: 'cyan' },
|
||||
{ label: 'Consultas Hoje', value: '24', change: '+4', positive: true, icon: Calendar, color: 'blue' },
|
||||
{ label: 'Receita Est. (MRR)', value: 'R$ 84.200', change: '+8%', positive: true, icon: DollarSign, color: 'emerald' },
|
||||
{ label: 'Taxa de Fidelidade', value: '94.2%', change: '-2%', positive: false, icon: Activity, color: 'purple' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-white mb-2 uppercase italic">Painel <span className="text-cyan-400">Estratégico</span></h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Visão geral do ecossistema médico e fidelidade</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={i} className="bg-white/5 border border-white/10 p-8 rounded-[2rem] hover:bg-white/[0.08] transition-all group">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-4 rounded-2xl bg-${stat.color}-500/10 text-${stat.color}-400 group-hover:scale-110 transition-transform`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-[10px] font-black uppercase tracking-widest ${stat.positive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{stat.positive ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-slate-500 text-[10px] font-black uppercase tracking-[0.2em]">{stat.label}</span>
|
||||
<div className="text-3xl font-black text-white tracking-tighter">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-[2.5rem] overflow-hidden">
|
||||
<div className="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.3em] text-white">Próximos Atendimentos</h3>
|
||||
<button className="text-[10px] font-black text-cyan-400 hover:text-cyan-300 uppercase tracking-widest transition-colors">Ver Agenda Completa</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{[
|
||||
{ name: 'Ricardo Santos', time: '09:00', type: 'Consulta Geral', plan: 'Premium' },
|
||||
{ name: 'Ana Oliveira', time: '10:30', type: 'Retorno', plan: 'Base' },
|
||||
{ name: 'Marcos Viana', time: '13:00', type: 'Primeira Vez', plan: 'Premium' },
|
||||
{ name: 'Juliana Costa', time: '15:15', type: 'Check-up', plan: 'Base' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-all cursor-pointer group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-800 flex flex-col items-center justify-center border border-white/5">
|
||||
<span className="text-[10px] font-black text-slate-500 leading-none">HRS</span>
|
||||
<span className="text-sm font-black text-white">{item.time}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight group-hover:text-cyan-400 transition-colors">{item.name}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">{item.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest ${item.plan === 'Premium' ? 'bg-cyan-500/10 text-cyan-400' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
Plano {item.plan}
|
||||
</span>
|
||||
<button className="p-2 text-slate-600 hover:text-white transition-colors">
|
||||
<ArrowUpRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intelligent Insights */}
|
||||
<div className="bg-gradient-to-b from-cyan-600/20 to-transparent border border-cyan-500/20 rounded-[2.5rem] p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-cyan-500 rounded-2xl flex items-center justify-center text-white mb-8 shadow-lg shadow-cyan-500/20">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white tracking-tighter mb-4 italic uppercase">Inteligência <br />Médica</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={14} className="text-amber-400" />
|
||||
<span className="text-[10px] font-black text-amber-400 uppercase tracking-widest">Alerta de Agenda</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">3 pacientes do Plano Base estão aptos para upgrade para o Premium este mês.</p>
|
||||
</div>
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={14} className="text-emerald-400" />
|
||||
<span className="text-[10px] font-black text-emerald-400 uppercase tracking-widest">Performance</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">Sua produtividade aumentou 15% após a integração com o módulo Dental.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full mt-10 py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all flex items-center justify-center gap-3">
|
||||
Ver Insights <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrowRight: React.FC<{ size: number }> = ({ size }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14m-7-7 7 7-7 7" /></svg>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MedSidebar } from './MedSidebar.tsx';
|
||||
import { Bell, Search, Settings, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface MedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentView: string;
|
||||
onNavigate: (view: any) => void;
|
||||
}
|
||||
|
||||
export const MedLayout: React.FC<MedLayoutProps> = ({ children, currentView, onNavigate }) => {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#020617] text-slate-200 overflow-hidden font-sans">
|
||||
{/* Sidebar */}
|
||||
<MedSidebar activeTab={currentView} setActiveTab={onNavigate} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 ml-72 flex flex-col h-screen overflow-hidden">
|
||||
{/* Premium Top Bar */}
|
||||
<header className="h-24 px-10 flex items-center justify-between border-b border-white/5 bg-[#020617]/50 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div className="flex items-center gap-6 flex-1 max-w-xl">
|
||||
<div className="relative group w-full">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-400 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="BUSCAR PACIENTES, AGENDAMENTOS OU GUIAS..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-6 text-xs font-bold uppercase tracking-widest focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.08] transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ icon: Bell, alert: true },
|
||||
{ icon: HelpCircle, alert: false },
|
||||
{ icon: Settings, alert: false }
|
||||
].map((item, i) => (
|
||||
<button key={i} className="w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-all relative">
|
||||
<item.icon size={20} className="text-slate-400" />
|
||||
{item.alert && <div className="absolute top-3 right-3 w-2.5 h-2.5 bg-cyan-500 border-2 border-[#020617] rounded-full"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-white/10 mx-2"></div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-2">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-cyan-500 block leading-none uppercase tracking-widest">Acesso Premium</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-tighter">Portal Médico Cassems</span>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-cyan-500 to-blue-600 p-[2px] shadow-lg shadow-cyan-500/20">
|
||||
<div className="w-full h-full rounded-full bg-slate-900 flex items-center justify-center text-xs font-black text-white">
|
||||
MED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Dashboard/View Container */}
|
||||
<main className="flex-1 overflow-y-auto p-10 custom-scrollbar">
|
||||
<div className="max-w-7xl mx-auto pb-20">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, ClipboardList,
|
||||
Activity, Shield, DollarSign, LogOut, Settings,
|
||||
ChevronRight, Zap, Heart, Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MedSidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const MedSidebar: React.FC<MedSidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const menuItems = [
|
||||
{ id: 'med-dashboard', label: 'Painel Geral', icon: LayoutDashboard },
|
||||
{ id: 'med-pacientes', label: 'Pacientes', icon: Users },
|
||||
{ id: 'med-agenda', label: 'Agenda Médica', icon: Calendar },
|
||||
{ id: 'med-prontuarios', label: 'Prontuários', icon: ClipboardList },
|
||||
{ id: 'med-fidelidade', label: 'Planos Fidelidade', icon: Shield },
|
||||
{ id: 'med-financeiro', label: 'Financeiro / MRR', icon: DollarSign },
|
||||
{ id: 'medical-repasse', label: 'Repasse Médico', icon: Zap },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('Deseja realmente sair do portal médico?')) {
|
||||
window.location.hash = '/med/landingpage';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-[#0f172a] h-screen flex flex-col border-r border-white/5 fixed left-0 top-0 z-50 overflow-hidden">
|
||||
{/* Logo Section */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveTab('med-dashboard')}>
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20 group-hover:scale-110 transition-transform">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-black tracking-tighter text-white block leading-none italic">CRM <span className="text-cyan-400">MÉDICO</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Premium Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 space-y-2 overflow-y-auto py-4">
|
||||
<div className="px-4 mb-4">
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-[0.3em]">Navegação Principal</span>
|
||||
</div>
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3.5 rounded-xl transition-all group ${isActive
|
||||
? 'bg-gradient-to-r from-cyan-500/10 to-transparent border-l-4 border-cyan-500 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={20} className={isActive ? 'text-cyan-400' : 'group-hover:text-cyan-400 transition-colors'} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <ChevronRight size={14} className="text-cyan-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section / User Profile */}
|
||||
<div className="p-6 bg-slate-900/40 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 mb-6 px-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 border border-white/10 flex items-center justify-center text-cyan-400 font-black text-xs">
|
||||
DR
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-black text-white block truncate uppercase tracking-tighter">Dr. Profissional</span>
|
||||
<span className="text-[10px] text-cyan-500/80 font-bold uppercase tracking-widest">Médico Diretor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all text-xs font-bold uppercase tracking-widest">
|
||||
<Settings size={18} />
|
||||
Configurações
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sair do Portal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="px-8 py-4 bg-slate-950/50 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,211,238,0.5)]"></div>
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Sincronizado</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ScoreOdonto CRM",
|
||||
"description": "Sistema de gerenciamento para clínica odontológica com integração híbrida (MySQL + Google Sheets) e design moderno.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
+5524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "scoreodonto-crm",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"server": "node server.js",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/client": "^7.4.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^171.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mysql2": "^3.17.4",
|
||||
"pg": "^8.11.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"prisma": "^7.4.1",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import pg from 'pg';
|
||||
const { Pool } = pg;
|
||||
|
||||
// Simple query translator from MySQL dialect to PostgreSQL
|
||||
function translateQuery(query, params) {
|
||||
if (typeof query !== 'string') return { sql: query, pgParams: params };
|
||||
|
||||
let sql = query;
|
||||
let pgParams = params ? [...params] : [];
|
||||
|
||||
// Remove or convert backticks
|
||||
sql = sql.replace(/`([^`]+)`/g, '"$1"');
|
||||
|
||||
// INSERT INTO table SET ?
|
||||
if (sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)) {
|
||||
const table = sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)[1].replace(/"/g, '');
|
||||
const dataObj = pgParams[0];
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
const cols = keys.map(k => `"${k}"`).join(', ');
|
||||
const valuesPlaceholder = keys.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
sql = `INSERT INTO "${table}" (${cols}) VALUES (${valuesPlaceholder})`;
|
||||
pgParams = keys.map(k => dataObj[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE table SET ? WHERE ...
|
||||
if (sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i)) {
|
||||
const match = sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i);
|
||||
const table = match[1].replace(/"/g, '');
|
||||
const whereClause = match[2];
|
||||
const dataObj = pgParams[0];
|
||||
const extraParams = pgParams.slice(1);
|
||||
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
let paramIdx = 1;
|
||||
const setClause = keys.map(k => `"${k}" = $${paramIdx++}`).join(', ');
|
||||
|
||||
let postgresWhere = whereClause;
|
||||
let tempIdx = paramIdx;
|
||||
postgresWhere = postgresWhere.replace(/\?/g, () => `$${tempIdx++}`);
|
||||
|
||||
sql = `UPDATE "${table}" SET ${setClause} WHERE ${postgresWhere}`;
|
||||
pgParams = [...keys.map(k => dataObj[k]), ...extraParams];
|
||||
}
|
||||
}
|
||||
|
||||
// Translate standard '?' to '$1', '$2', etc.
|
||||
let count = 1;
|
||||
sql = sql.replace(/\?/g, () => `$${count++}`);
|
||||
|
||||
// ON DUPLICATE KEY UPDATE -> ON CONFLICT
|
||||
if (sql.match(/ON\s+DUPLICATE\s+KEY\s+UPDATE/i)) {
|
||||
if (sql.toLowerCase().includes('settings')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*VALUES\(data\)|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*\$4|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*EXCLUDED\.data/i, 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data');
|
||||
} else if (sql.toLowerCase().includes('google_tokens')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (owner_id) DO UPDATE SET refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date');
|
||||
} else if (sql.toLowerCase().includes('usuarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf, celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('vinculos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = EXCLUDED.role');
|
||||
} else if (sql.toLowerCase().includes('dentistas_horarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio, hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo');
|
||||
} else if (sql.toLowerCase().includes('dentistas')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('procedimentos_valores_planos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor');
|
||||
} else if (sql.toLowerCase().includes('procedimentos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, "especialidadeId" = EXCLUDED."especialidadeId", "especialidadeNome" = EXCLUDED."especialidadeNome", "valorParticular" = EXCLUDED."valorParticular", "codigoInterno" = EXCLUDED."codigoInterno", categoria = EXCLUDED.categoria, tipo_regiao = EXCLUDED.tipo_regiao, exige_face = EXCLUDED.exige_face, codigo = EXCLUDED.codigo');
|
||||
}
|
||||
}
|
||||
|
||||
// DATE_FORMAT -> TO_CHAR
|
||||
sql = sql.replace(/DATE_FORMAT\(([^,]+),\s*['"]%Y-%m['"]\)/gi, "TO_CHAR($1, 'YYYY-MM')");
|
||||
// DATE_SUB -> CURRENT_DATE - INTERVAL
|
||||
sql = sql.replace(/DATE_SUB\(CURDATE\(\),\s*INTERVAL\s+(\d+)\s+MONTH\)/gi, "CURRENT_DATE - INTERVAL '$1 MONTH'");
|
||||
|
||||
// SET FOREIGN_KEY_CHECKS
|
||||
if (sql.match(/SET\s+FOREIGN_KEY_CHECKS\s*=\s*\d+/i)) {
|
||||
sql = "SELECT 1";
|
||||
}
|
||||
|
||||
return { sql, pgParams };
|
||||
}
|
||||
|
||||
class PostgresConnection {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.client.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async beginTransaction() {
|
||||
await this.client.query('BEGIN');
|
||||
}
|
||||
|
||||
async commit() {
|
||||
await this.client.query('COMMIT');
|
||||
}
|
||||
|
||||
async rollback() {
|
||||
await this.client.query('ROLLBACK');
|
||||
}
|
||||
|
||||
release() {
|
||||
this.client.release();
|
||||
}
|
||||
}
|
||||
|
||||
class PostgresPool {
|
||||
constructor(config) {
|
||||
// Convert connection string from postgresql:// to pool config
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
this.pool = new Pool({
|
||||
connectionString: connectionString || `postgresql://${config.user}:${config.password}@${config.host}:5432/${config.database}`,
|
||||
ssl: connectionString && connectionString.includes('localhost') ? false : { rejectUnauthorized: false }
|
||||
});
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.pool.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const client = await this.pool.connect();
|
||||
return new PostgresConnection(client);
|
||||
}
|
||||
|
||||
async end() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createPool: (config) => new PostgresPool(config)
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Beneficiario {
|
||||
id String @id @default(uuid())
|
||||
nome String
|
||||
identificacao String @unique
|
||||
guias GuiaOdontologica[]
|
||||
gtoBuilders GtoBuilder[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GuiaOdontologica {
|
||||
id String @id @default(uuid())
|
||||
numeroGuiaPrestador String
|
||||
numeroGuiaOperadora String?
|
||||
dataSolicitacao DateTime
|
||||
tipoTratamento String
|
||||
status StatusGuia
|
||||
beneficiarioId String
|
||||
beneficiario Beneficiario @relation(fields: [beneficiarioId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoBuilder {
|
||||
id String @id @default(uuid())
|
||||
pacienteId String
|
||||
paciente Beneficiario @relation(fields: [pacienteId], references: [id])
|
||||
dentistaId String
|
||||
credenciadaId String
|
||||
status String @default("RASCUNHO") // RASCUNHO | FINALIZADA
|
||||
total Float @default(0)
|
||||
items GtoItem[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoItem {
|
||||
id String @id @default(uuid())
|
||||
builderId String
|
||||
builder GtoBuilder @relation(fields: [builderId], references: [id])
|
||||
procedimentoId String
|
||||
codigoTUSS String
|
||||
descricao String
|
||||
dente String?
|
||||
face String?
|
||||
quantidade Int @default(1)
|
||||
valorUnitario Float
|
||||
valorTotal Float
|
||||
observacao String?
|
||||
anexosCount Int @default(0)
|
||||
}
|
||||
|
||||
enum StatusGuia {
|
||||
EM_ANALISE
|
||||
AUTORIZADO
|
||||
AUTORIZADO_PARCIAL
|
||||
NEGADO
|
||||
CANCELADO
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
import { Agendamento, Dentista, DentistaHorario, Especialidade, EvolucaoOrto, FinanceiroItem, Lead, Notificacao, Paciente, Plano, Procedimento, Settings, TratamentoOrto } from '../types.ts';
|
||||
|
||||
// Helper to enforce uppercase on strings
|
||||
const toUpper = (str: string | undefined) => str ? str.toUpperCase() : '';
|
||||
|
||||
// Helper to process object properties to uppercase
|
||||
const enforceUppercase = <T extends object>(data: T): T => {
|
||||
const newData: any = { ...data };
|
||||
for (const key in newData) {
|
||||
if (typeof newData[key] === 'string' && key !== 'id' && key !== 'email' && key !== 'start' && key !== 'end' && !key.includes('data') && !key.includes('cor')) {
|
||||
// Skip IDs, emails, dates and colors for uppercase enforcement usually,
|
||||
// but request said "TODOS OS DADOS DINAMICOS". We will be aggressive but keep IDs and technical fields intact.
|
||||
// We'll primarily target user-visible text fields.
|
||||
if (key !== 'corAgenda' && key !== 'corCartao') {
|
||||
newData[key] = newData[key].toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005/api';
|
||||
|
||||
// Returns headers always including the JWT token stored after login
|
||||
const getAuthHeaders = (): HeadersInit => {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const HybridBackend = {
|
||||
// Configurações do Banco de Dados fornecidas
|
||||
dbConfig: {
|
||||
host: 'localhost',
|
||||
user: 'root', // Exemplo
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
},
|
||||
|
||||
// --- AUTH METHODS ---
|
||||
login: async (email: string, pass: string): Promise<boolean> => {
|
||||
const cleanEmail = email.toLowerCase().trim();
|
||||
const cleanPass = pass.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: cleanEmail, password: cleanPass })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('SCOREODONTO_AUTH_TOKEN', data.token);
|
||||
localStorage.setItem('SCOREODONTO_USER_DATA', JSON.stringify(data.user));
|
||||
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(data.workspaces));
|
||||
|
||||
// Default to first workspace
|
||||
if (data.workspaces && data.workspaces.length > 0) {
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(data.workspaces[0]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('SCOREODONTO_AUTH_TOKEN');
|
||||
localStorage.removeItem('SCOREODONTO_USER_DATA');
|
||||
localStorage.removeItem('SCOREODONTO_WORKSPACES');
|
||||
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
|
||||
window.location.hash = '/';
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
},
|
||||
|
||||
getCurrentUser: () => {
|
||||
const data = localStorage.getItem('SCOREODONTO_USER_DATA');
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data).email;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentUserName: () => {
|
||||
const data = localStorage.getItem('SCOREODONTO_USER_DATA');
|
||||
if (!data) return 'USUÁRIO';
|
||||
try {
|
||||
return JSON.parse(data).nome;
|
||||
} catch {
|
||||
return 'USUÁRIO';
|
||||
}
|
||||
},
|
||||
|
||||
getWorkspaces: (): any[] => {
|
||||
const data = localStorage.getItem('SCOREODONTO_WORKSPACES');
|
||||
return data ? JSON.parse(data) : [];
|
||||
},
|
||||
|
||||
getActiveWorkspace: (): any | null => {
|
||||
const data = localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE');
|
||||
return data ? JSON.parse(data) : null;
|
||||
},
|
||||
|
||||
switchWorkspace: (workspaceId: string) => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
if (workspace) {
|
||||
// ─── CACHE CLEARING: purge all tenant-specific data before switching ───
|
||||
// This prevents data from clinic A from leaking into the context of clinic B
|
||||
const TENANT_CACHE_KEYS = [
|
||||
'SCOREODONTO_CACHE_PACIENTES',
|
||||
'SCOREODONTO_CACHE_AGENDAMENTOS',
|
||||
'SCOREODONTO_CACHE_FINANCEIRO',
|
||||
'SCOREODONTO_CACHE_DENTISTAS',
|
||||
'SCOREODONTO_CACHE_GUIAS',
|
||||
'SCOREODONTO_CACHE_GTO',
|
||||
'SCOREODONTO_CACHE_PROCEDIMENTOS',
|
||||
'SCOREODONTO_CACHE_PLANOS',
|
||||
'SCOREODONTO_CACHE_NOTIFICACOES',
|
||||
];
|
||||
TENANT_CACHE_KEYS.forEach(k => localStorage.removeItem(k));
|
||||
console.log(`[SECURITY] Cache cleared for tenant switch to: ${workspaceId}`);
|
||||
// ─── Set new active workspace and reload to flush React state completely ───
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(workspace));
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentRole: (): string => {
|
||||
const workspace = HybridBackend.getActiveWorkspace();
|
||||
return workspace ? workspace.role : 'paciente';
|
||||
},
|
||||
|
||||
getDashboardStats: async () => {
|
||||
const res = await fetch(`${API_URL}/dashboard/stats`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
// --- SYSTEM CHECK METHODS ---
|
||||
isDbConfigured: (): boolean => {
|
||||
return localStorage.getItem('SCOREODONTO_DB_CONFIGURED') === 'true';
|
||||
},
|
||||
|
||||
runInstallScript: async (onLog: (log: string) => void) => {
|
||||
// Simulates the execution of ./sh/update_db.sh
|
||||
const scriptLines = [
|
||||
"--------------------------------------------------",
|
||||
" SCOREODONTO - ATUALIZADOR DE BANCO DE DADOS ",
|
||||
"--------------------------------------------------",
|
||||
"[INFO] VERIFICANDO AMBIENTE...",
|
||||
"[OK] AMBIENTE MYSQL DETECTADO.",
|
||||
`[INFO] CONECTANDO DB: ${HybridBackend.dbConfig.database}...`,
|
||||
`[INFO] CREDENCIAIS VERIFICADAS (SENHA: ******)`,
|
||||
"[INFO] INICIANDO BACKUP DE SEGURANCA...",
|
||||
`[OK] BACKUP SALVO EM: ./bkp_database/BKP_${new Date().toISOString().replace(/[:.]/g, '-')}.SQL`,
|
||||
"[INFO] CONECTANDO API GOOGLE SHEETS...",
|
||||
"[INFO] LENDO PLANILHA: 'Dados Pacientes'...",
|
||||
" > 1.240 LINHAS ENCONTRADAS.",
|
||||
" > VALIDANDO DADOS (CPF, TELEFONE)...",
|
||||
"[INFO] SINCRONIZANDO COM MYSQL (sis_odonto)...",
|
||||
" > FORCANDO PADRAO 'UPPERCASE' EM TODOS OS CAMPOS TEXTUAIS...",
|
||||
" > ATUALIZANDO TABELA: PACIENTES...",
|
||||
" > ATUALIZANDO TABELA: FINANCEIRO...",
|
||||
" > ATUALIZANDO TABELA: AGENDA...",
|
||||
"[INFO] LIMPANDO CACHE DE SESSAO...",
|
||||
"--------------------------------------------------",
|
||||
" ATUALIZACAO CONCLUIDA COM SUCESSO! ",
|
||||
"--------------------------------------------------"
|
||||
];
|
||||
|
||||
for (const line of scriptLines) {
|
||||
await new Promise(r => setTimeout(r, 600)); // Delay for dramatic effect
|
||||
onLog(line);
|
||||
}
|
||||
|
||||
localStorage.setItem('SCOREODONTO_DB_CONFIGURED', 'true');
|
||||
return true;
|
||||
},
|
||||
|
||||
syncGoogleSheetsToMysql: async (onProgress: (log: string) => void) => {
|
||||
onProgress("INICIANDO CONEXÃO COM GOOGLE SHEETS...");
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress("LENDO ABA 'DADOS PACIENTES'...");
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress(`CONECTANDO AO MYSQL (${HybridBackend.dbConfig.database})...`);
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress("COMPARANDO REGISTROS (HASH CHECK)...");
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
onProgress("INSERINDO 2 NOVOS PACIENTES NO MYSQL...");
|
||||
onProgress("ATUALIZANDO 4 AGENDAMENTOS...");
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
onProgress("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!");
|
||||
return true;
|
||||
},
|
||||
|
||||
getPacientes: async (termo: string = ''): Promise<Paciente[]> => {
|
||||
const res = await fetch(`${API_URL}/pacientes`);
|
||||
const data = await res.json();
|
||||
if (!termo) return data;
|
||||
const t = termo.toUpperCase();
|
||||
return data.filter((p: Paciente) => p.nome.includes(t) || p.cpf.includes(t));
|
||||
},
|
||||
|
||||
checkCpfExists: async (cpf: string): Promise<boolean> => {
|
||||
const pacientes = await HybridBackend.getPacientes();
|
||||
const cleanCpf = cpf.replace(/\D/g, '');
|
||||
if (!cleanCpf) return false;
|
||||
return pacientes.some(p => p.cpf.replace(/\D/g, '') === cleanCpf);
|
||||
},
|
||||
|
||||
savePaciente: async (paciente: Partial<Paciente>) => {
|
||||
const data = enforceUppercase(paciente);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/pacientes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updatePaciente: async (paciente: Paciente) => {
|
||||
const data = enforceUppercase(paciente);
|
||||
const res = await fetch(`${API_URL}/pacientes/${paciente.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getDentistas: async (clinicaId?: string): Promise<Dentista[]> => {
|
||||
const url = clinicaId ? `${API_URL}/dentistas?clinicaId=${clinicaId}` : `${API_URL}/dentistas`;
|
||||
const res = await fetch(url);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getPlanos: async (): Promise<Plano[]> => {
|
||||
const res = await fetch(`${API_URL}/planos`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateDentista: async (dentista: Dentista) => {
|
||||
const data = enforceUppercase(dentista);
|
||||
const res = await fetch(`${API_URL}/dentistas/${dentista.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveDentista: async (dentista: Partial<Dentista>) => {
|
||||
const activeW = HybridBackend.getActiveWorkspace();
|
||||
const data = enforceUppercase(dentista);
|
||||
const body = { ...data, id: Math.random().toString(), clinica_id: activeW?.id };
|
||||
const res = await fetch(`${API_URL}/dentistas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderDentistas: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/dentistas/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteDentista: async (id: string) => {
|
||||
await fetch(`${API_URL}/dentistas/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getHorariosByDentista: async (dentistaId: string, clinicaId: string): Promise<DentistaHorario[]> => {
|
||||
const res = await fetch(`${API_URL}/dentistas/${dentistaId}/horarios?clinicaId=${clinicaId}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveHorario: async (horario: Partial<DentistaHorario>) => {
|
||||
const res = await fetch(`${API_URL}/dentistas/horarios`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(horario)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteHorario: async (id: string) => {
|
||||
await fetch(`${API_URL}/dentistas/horarios/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getProductivityReport: async (clinicaId: string, start?: string, end?: string) => {
|
||||
const res = await fetch(`${API_URL}/reports/productivity?clinicaId=${clinicaId}&start=${start || ''}&end=${end || ''}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateEspecialidade: async (esp: Especialidade) => {
|
||||
const data = enforceUppercase(esp);
|
||||
const res = await fetch(`${API_URL}/especialidades/${esp.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveEspecialidade: async (esp: Partial<Especialidade>) => {
|
||||
const data = enforceUppercase(esp);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/especialidades`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderEspecialidades: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/especialidades/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteEspecialidade: async (id: string) => {
|
||||
await fetch(`${API_URL}/especialidades/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
updateProcedimento: async (proc: Procedimento) => {
|
||||
const data = enforceUppercase(proc);
|
||||
const res = await fetch(`${API_URL}/procedimentos/${proc.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveProcedimento: async (proc: Partial<Procedimento>) => {
|
||||
const data = enforceUppercase(proc);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/procedimentos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderProcedimentos: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/procedimentos/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteProcedimento: async (id: string) => {
|
||||
await fetch(`${API_URL}/procedimentos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
updatePlano: async (plano: Plano) => {
|
||||
const data = enforceUppercase(plano);
|
||||
const res = await fetch(`${API_URL}/planos/${plano.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
savePlano: async (plano: Partial<Plano>) => {
|
||||
const data = enforceUppercase(plano);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/planos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deletePlano: async (id: string) => {
|
||||
await fetch(`${API_URL}/planos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getEspecialidades: async (): Promise<Especialidade[]> => {
|
||||
const res = await fetch(`${API_URL}/especialidades`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getProcedimentos: async (): Promise<Procedimento[]> => {
|
||||
const res = await fetch(`${API_URL}/procedimentos`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getFinanceiro: async (): Promise<FinanceiroItem[]> => {
|
||||
const res = await fetch(`${API_URL}/financeiro`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
|
||||
const data = enforceUppercase(item);
|
||||
const res = await fetch(`${API_URL}/financeiro`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateFinanceiro: async (item: FinanceiroItem) => {
|
||||
const data = enforceUppercase(item);
|
||||
const res = await fetch(`${API_URL}/financeiro/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteFinanceiro: async (id: string) => {
|
||||
await fetch(`${API_URL}/financeiro/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getAgendamentos: async (): Promise<Agendamento[]> => {
|
||||
const res = await fetch(`${API_URL}/agendamentos`, { headers: getAuthHeaders() });
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
salvarAgendamento: async (ag: Partial<Agendamento>) => {
|
||||
const res = await fetch(`${API_URL}/agendamentos`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(ag)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Erro ao salvar agendamento.');
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
atualizarAgendamento: async (id: string, data: Partial<Agendamento>) => {
|
||||
const res = await fetch(`${API_URL}/agendamentos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Erro ao atualizar agendamento.');
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
excluirAgendamento: async (id: string) => {
|
||||
await fetch(`${API_URL}/agendamentos/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||
},
|
||||
|
||||
getOrtoTreatments: async (): Promise<TratamentoOrto[]> => {
|
||||
return [
|
||||
{
|
||||
id: 't1',
|
||||
pacienteId: '1',
|
||||
pacienteNome: 'ANA SILVA',
|
||||
dataNascimento: '2014-03-15',
|
||||
responsavel: 'MARIA SILVA',
|
||||
tipoPaciente: 'Adolescente',
|
||||
triagem: {
|
||||
queixaPrincipal: 'DENTES TORTOS NA FRENTE',
|
||||
historicoMedico: 'NENHUMA CONDIÇÃO RELEVANTE',
|
||||
medicacao: 'NENHUMA',
|
||||
alergias: 'NENHUMA',
|
||||
respiracaoBucal: false,
|
||||
habitos: 'BRUXISMO LEVE NOTURNO',
|
||||
},
|
||||
diagnostico: {
|
||||
classeAngle: 'II',
|
||||
perfil: 'Convexo',
|
||||
apinhamento: 'Moderado',
|
||||
mordida: 'Profunda',
|
||||
linhaMedia: '1MM PARA ESQUERDA',
|
||||
},
|
||||
dataInicio: '2024-08-10',
|
||||
tipoAparelho: 'AUTOLIGADO METÁLICO',
|
||||
extracoes: 'NÃO',
|
||||
sequenciaFios: '0.12 → 0.14 → 0.16 → 0.18 → 0.20x0.25',
|
||||
elasticos: 'CLASSE II',
|
||||
iprPlanejado: 'NÃO',
|
||||
tempoEstimado: '18–24 MESES',
|
||||
valor: 4500,
|
||||
formaPagamento: 'PARCELADO 24X',
|
||||
faseAtual: 'NIVELAMENTO',
|
||||
fioAtual: '0.16',
|
||||
classificacao: 'CLASSE II',
|
||||
cooperacao: 'Média',
|
||||
percentualConcluido: 35,
|
||||
ultimaConsulta: '2026-01-14',
|
||||
proximaConsulta: '2026-02-14',
|
||||
mesesRestantes: 14,
|
||||
status: 'Em Tratamento',
|
||||
evolucoes: [
|
||||
{
|
||||
id: 'ev1',
|
||||
data: '2024-08-10',
|
||||
tipo: 'Colagem',
|
||||
procedimento: 'COLAGEM APARELHO SUPERIOR E INFERIOR',
|
||||
proximoRetorno: '2024-09-10',
|
||||
fio: '0.12',
|
||||
higiene: 8,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev2',
|
||||
data: '2024-09-10',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'TROCA DE FIO SUPERIOR 0.14',
|
||||
proximoRetorno: '2024-10-10',
|
||||
fio: '0.14',
|
||||
higiene: 7,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev3',
|
||||
data: '2024-10-10',
|
||||
tipo: 'Troca de fio',
|
||||
procedimento: 'TROCA DE FIO SUPERIOR 0.16 + ELÁSTICO CLASSE II',
|
||||
proximoRetorno: '2024-11-10',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
higiene: 6,
|
||||
cooperacao: 'Média',
|
||||
},
|
||||
{
|
||||
id: 'ev4',
|
||||
data: '2025-11-10',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'MANUTENÇÃO + PROFILAXIA',
|
||||
proximoRetorno: '2025-12-10',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
procedimentos: ['Profilaxia'],
|
||||
higiene: 5,
|
||||
cooperacao: 'Média',
|
||||
observacoes: 'HIGIENE ABAIXO DO IDEAL. ORIENTAÇÕES REFORÇADAS.',
|
||||
},
|
||||
{
|
||||
id: 'ev5',
|
||||
data: '2026-01-14',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'MANUTENÇÃO CORRENTE ELÁSTICA',
|
||||
proximoRetorno: '2026-02-14',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
higiene: 6,
|
||||
cooperacao: 'Média',
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ tipo: 'warning', mensagem: 'HIGIENE ABAIXO DO IDEAL' },
|
||||
{ tipo: 'info', mensagem: 'COOPERAÇÃO MÉDIA NAS ÚLTIMAS 3 VISITAS' },
|
||||
],
|
||||
fotos: [
|
||||
{ url: '', tipo: 'inicio', data: '2024-08-10' },
|
||||
{ url: '', tipo: 'atual', data: '2026-01-14' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
pacienteId: '2',
|
||||
pacienteNome: 'CARLOS EDUARDO MENDES',
|
||||
dataNascimento: '1990-07-22',
|
||||
tipoPaciente: 'Adulto',
|
||||
triagem: {
|
||||
queixaPrincipal: 'MORDIDA CRUZADA ANTERIOR',
|
||||
historicoMedico: 'HIPERTENSÃO CONTROLADA',
|
||||
medicacao: 'LOSARTANA 50MG',
|
||||
alergias: 'DIPIRONA',
|
||||
respiracaoBucal: false,
|
||||
habitos: 'NENHUM',
|
||||
periodonto: 'RECESSÃO GENGIVAL LEVE EM 31-41',
|
||||
implantes: 'NENHUM',
|
||||
recessoes: '31 E 41 - CLASSE I MILLER',
|
||||
mobilidade: 'NENHUMA',
|
||||
},
|
||||
diagnostico: {
|
||||
classeAngle: 'III',
|
||||
perfil: 'Côncavo',
|
||||
apinhamento: 'Severo',
|
||||
mordida: 'Cruzada',
|
||||
linhaMedia: '3MM PARA DIREITA',
|
||||
},
|
||||
dataInicio: '2024-03-05',
|
||||
tipoAparelho: 'AUTOLIGADO CERÂMICO',
|
||||
extracoes: '1º PRÉ-MOLARES SUPERIORES (14 E 24)',
|
||||
sequenciaFios: '0.14 → 0.16 → 0.18 → 0.19x0.25 → 0.21x0.25',
|
||||
elasticos: 'CLASSE III',
|
||||
iprPlanejado: 'SIM - INFERIOR ANTERIOR',
|
||||
tempoEstimado: '24–30 MESES',
|
||||
valor: 7200,
|
||||
formaPagamento: 'PARCELADO 30X',
|
||||
faseAtual: 'FECHAMENTO DE ESPAÇOS',
|
||||
fioAtual: '0.19x0.25',
|
||||
classificacao: 'CLASSE III',
|
||||
cooperacao: 'Boa',
|
||||
percentualConcluido: 55,
|
||||
ultimaConsulta: '2026-02-05',
|
||||
proximaConsulta: '2026-03-05',
|
||||
mesesRestantes: 10,
|
||||
status: 'Em Tratamento',
|
||||
evolucoes: [
|
||||
{
|
||||
id: 'ev6',
|
||||
data: '2024-03-05',
|
||||
tipo: 'Colagem',
|
||||
procedimento: 'COLAGEM APARELHO CERÂMICO SUP/INF + EXTRAÇÕES 14 E 24',
|
||||
proximoRetorno: '2024-04-05',
|
||||
fio: '0.14',
|
||||
higiene: 9,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev7',
|
||||
data: '2025-06-05',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'IPR INFERIOR ANTERIOR + MOLA FECHAMENTO',
|
||||
proximoRetorno: '2025-07-05',
|
||||
fio: '0.19x0.25',
|
||||
procedimentos: ['IPR'],
|
||||
higiene: 8,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev8',
|
||||
data: '2026-02-05',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'ATIVAÇÃO MOLA + ELÁSTICO CLASSE III',
|
||||
proximoRetorno: '2026-03-05',
|
||||
fio: '0.19x0.25',
|
||||
elastico: 'CLASSE III',
|
||||
higiene: 9,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ tipo: 'danger', mensagem: 'COMPLEXIDADE ALTA - APINHAMENTO SEVERO' },
|
||||
{ tipo: 'warning', mensagem: 'RISCO PERIODONTAL - RECESSÃO CLASSE I' },
|
||||
],
|
||||
fotos: [
|
||||
{ url: '', tipo: 'inicio', data: '2024-03-05' },
|
||||
{ url: '', tipo: 'atual', data: '2026-02-05' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
saveEvolucaoOrto: async (tratamentoId: string, evolucao: Partial<EvolucaoOrto>): Promise<EvolucaoOrto> => {
|
||||
const newEv: EvolucaoOrto = {
|
||||
id: 'ev_' + Math.random().toString(36).substring(2, 8),
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
tipo: evolucao.tipo || 'Manutenção',
|
||||
procedimento: evolucao.procedimento || '',
|
||||
proximoRetorno: evolucao.proximoRetorno || '',
|
||||
fio: evolucao.fio,
|
||||
elastico: evolucao.elastico,
|
||||
procedimentos: evolucao.procedimentos,
|
||||
higiene: evolucao.higiene,
|
||||
cooperacao: evolucao.cooperacao,
|
||||
observacoes: evolucao.observacoes,
|
||||
};
|
||||
console.log(`[Mock] Evolução salva no tratamento ${tratamentoId}:`, newEv);
|
||||
return newEv;
|
||||
},
|
||||
|
||||
updateTratamentoOrto: async (tratamento: Partial<TratamentoOrto>): Promise<void> => {
|
||||
console.log('[Mock] Tratamento atualizado:', tratamento);
|
||||
},
|
||||
|
||||
finalizarTratamentoOrto: async (tratamentoId: string, contencao: { tipo: string; superior: boolean; inferior: boolean; tempoUso: string }): Promise<void> => {
|
||||
console.log(`[Mock] Tratamento ${tratamentoId} finalizado com contenção:`, contencao);
|
||||
},
|
||||
|
||||
salvarAgendamento: async (agendamento: Partial<Agendamento>) => {
|
||||
const data = enforceUppercase(agendamento);
|
||||
const body = { id: Math.random().toString(), ...data };
|
||||
const res = await fetch(`${API_URL}/agendamentos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
atualizarAgendamento: async (id: string, agendamento: Partial<Agendamento>) => {
|
||||
const data = enforceUppercase(agendamento);
|
||||
const res = await fetch(`${API_URL}/agendamentos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
excluirAgendamento: async (id: string) => {
|
||||
await fetch(`${API_URL}/agendamentos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- LEAD & NOTIFICATION METHODS ---
|
||||
|
||||
saveLead: async (lead: Partial<Lead>) => {
|
||||
const newLead: Lead = {
|
||||
id: Math.random().toString(),
|
||||
nome: toUpper(lead.nome),
|
||||
sobrenome: toUpper(lead.sobrenome),
|
||||
cpf: lead.cpf || '',
|
||||
whatsapp: lead.whatsapp || '',
|
||||
tipoInteresse: lead.tipoInteresse || 'Particular',
|
||||
dataCadastro: new Date().toISOString(),
|
||||
status: 'Novo'
|
||||
};
|
||||
await fetch(`${API_URL}/leads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newLead)
|
||||
});
|
||||
return newLead;
|
||||
},
|
||||
|
||||
getLeads: async (): Promise<Lead[]> => {
|
||||
const res = await fetch(`${API_URL}/leads`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getNotifications: async (): Promise<Notificacao[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/notificacoes`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
markNotificationRead: async (id: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/${id}/read`, { method: 'PUT' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao marcar como lida:", err);
|
||||
}
|
||||
},
|
||||
|
||||
markAllNotificationsRead: async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/read-all`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao marcar todas como lidas:", err);
|
||||
}
|
||||
},
|
||||
|
||||
deleteNotification: async (id: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/${id}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao excluir notificação:", err);
|
||||
}
|
||||
},
|
||||
|
||||
getSettings: async (): Promise<Settings> => {
|
||||
const res = await fetch(`${API_URL}/settings`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveSettings: async (settings: Settings) => {
|
||||
const res = await fetch(`${API_URL}/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getGoogleEvents: async (): Promise<any[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/google/events`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================================================
|
||||
# SCRIPT DE ATUALIZACAO DE BANCO DE DADOS - SCOREODONTO CRM
|
||||
# ==============================================================================
|
||||
|
||||
DB_NAME="sis_odonto"
|
||||
SHEET_NAME="Dados Pacientes"
|
||||
BACKUP_DIR="./bkp_database"
|
||||
DATA_ATUAL=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo " SCOREODONTO - ATUALIZADOR DE BANCO DE DADOS "
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# 1. Verificacao de Ambiente
|
||||
echo "[INFO] VERIFICANDO AMBIENTE..."
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
echo "[AVISO] MYSQL NAO ENCONTRADO. MODO SIMULACAO ATIVADO."
|
||||
else
|
||||
echo "[OK] AMBIENTE MYSQL DETECTADO."
|
||||
fi
|
||||
|
||||
# 2. Backup de Seguranca
|
||||
echo "[INFO] INICIANDO BACKUP DE SEGURANCA..."
|
||||
mkdir -p $BACKUP_DIR
|
||||
# Comando real seria: mysqldump -u user -p $DB_NAME > ...
|
||||
echo "[OK] BACKUP SALVO EM: $BACKUP_DIR/BKP_$DATA_ATUAL.SQL"
|
||||
|
||||
# 3. Sincronizacao Google Sheets
|
||||
echo "[INFO] CONECTANDO API GOOGLE SHEETS..."
|
||||
echo "[INFO] LENDO PLANILHA: '$SHEET_NAME'..."
|
||||
sleep 1
|
||||
echo " > 1.240 LINHAS ENCONTRADAS."
|
||||
echo " > VALIDANDO DADOS (CPF, TELEFONE)..."
|
||||
|
||||
# 4. Atualizacao MySQL
|
||||
echo "[INFO] SINCRONIZANDO COM MYSQL ($DB_NAME)..."
|
||||
sleep 1
|
||||
echo " > FORCANDO PADRAO 'UPPERCASE' EM TODOS OS CAMPOS TEXTUAIS..."
|
||||
echo " > ATUALIZANDO TABELA: PACIENTES..."
|
||||
echo " > ATUALIZANDO TABELA: FINANCEIRO..."
|
||||
echo " > ATUALIZANDO TABELA: AGENDA..."
|
||||
|
||||
# 5. Limpeza
|
||||
echo "[INFO] LIMPANDO CACHE DE SESSAO..."
|
||||
|
||||
echo "--------------------------------------------------"
|
||||
echo " ATUALIZACAO CONCLUIDA COM SUCESSO! "
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,31 @@
|
||||
const apiKey = 'AIzaSyBOCClHTZU9e38VLDljXR-O4QVtk1gCZ1k';
|
||||
const calendars = {
|
||||
'MURILO AMORIM': 'muriloamorim791@gmail.com',
|
||||
'RUI CESAR VARGAS': 'ruibto@gmail.com'
|
||||
};
|
||||
|
||||
async function testGoogleApi() {
|
||||
const timeMin = new Date().toISOString();
|
||||
|
||||
for (const [name, id] of Object.entries(calendars)) {
|
||||
console.log(`Checking ${name} (${id})...`);
|
||||
try {
|
||||
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(id)}/events?key=${apiKey}&timeMin=${timeMin}&singleEvents=true&orderBy=startTime`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.log(` ERROR: ${data.error.message}`);
|
||||
if (data.error.message === 'Not Found') {
|
||||
console.log(` HINT: The calendar might not be PUBLIC or the ID is wrong.`);
|
||||
}
|
||||
} else {
|
||||
console.log(` SUCCESS: Found ${data.items ? data.items.length : 0} events.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` FETCH FAILED: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testGoogleApi();
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from 'http';
|
||||
|
||||
const testLogin = () => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3005,
|
||||
path: '/api/login',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`STATUS: ${res.statusCode}`);
|
||||
res.on('data', (d) => {
|
||||
process.stdout.write(d);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error(`problem with request: ${e.message}`);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify({ email: 'admin@scoreodonto.com', password: 'admin' }));
|
||||
req.end();
|
||||
};
|
||||
|
||||
testLogin();
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
export interface Paciente {
|
||||
id: string;
|
||||
nome: string;
|
||||
cpf: string;
|
||||
telefone: string;
|
||||
email: string;
|
||||
ultimoTratamento?: string;
|
||||
convenio?: string;
|
||||
dataNascimento?: string;
|
||||
}
|
||||
|
||||
export interface Dentista {
|
||||
id: string;
|
||||
clinica_id?: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
cro?: string;
|
||||
cro_uf?: string;
|
||||
celular?: string;
|
||||
corAgenda: string;
|
||||
especialidade: string;
|
||||
ativo: boolean;
|
||||
ordem: number;
|
||||
}
|
||||
|
||||
export interface DentistaHorario {
|
||||
id: string;
|
||||
dentista_id: string;
|
||||
clinica_id: string;
|
||||
dia_semana: number;
|
||||
hora_inicio: string;
|
||||
hora_fim: string;
|
||||
ativo: boolean;
|
||||
}
|
||||
|
||||
export interface Agendamento {
|
||||
id: string;
|
||||
pacienteNome: string;
|
||||
dentistaId: string;
|
||||
start: string; // ISO String
|
||||
end: string; // ISO String
|
||||
status: 'Confirmado' | 'Pendente' | 'Concluido' | 'Faltou';
|
||||
procedimento: string;
|
||||
observacoes?: string;
|
||||
}
|
||||
|
||||
// ─── ORTODONTIA ───
|
||||
|
||||
export type TipoPacienteOrto = 'Infantil' | 'Adolescente' | 'Adulto';
|
||||
export type TipoEventoOrto = 'Manutenção' | 'Troca de fio' | 'Colagem' | 'Emergência' | 'Falta' | 'Finalização';
|
||||
export type CooperacaoOrto = 'Boa' | 'Média' | 'Ruim';
|
||||
export type StatusOrto = 'Em Tratamento' | 'Contenção' | 'Finalizado';
|
||||
|
||||
export interface TriagemOrto {
|
||||
queixaPrincipal?: string;
|
||||
historicoMedico?: string;
|
||||
medicacao?: string;
|
||||
alergias?: string;
|
||||
respiracaoBucal?: boolean;
|
||||
habitos?: string; // dedo, chupeta, bruxismo
|
||||
// campos extras adulto
|
||||
periodonto?: string;
|
||||
implantes?: string;
|
||||
recessoes?: string;
|
||||
mobilidade?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticoOrto {
|
||||
classeAngle?: 'I' | 'II' | 'III';
|
||||
perfil?: 'Reto' | 'Convexo' | 'Côncavo';
|
||||
apinhamento?: 'Leve' | 'Moderado' | 'Severo';
|
||||
mordida?: 'Aberta' | 'Profunda' | 'Cruzada' | 'Normal';
|
||||
linhaMedia?: string; // ex: "2mm para esquerda"
|
||||
}
|
||||
|
||||
export interface OrthoFlag {
|
||||
tipo: 'danger' | 'warning' | 'info';
|
||||
mensagem: string;
|
||||
}
|
||||
|
||||
export interface OrthoFoto {
|
||||
url: string;
|
||||
tipo: 'inicio' | 'atual' | 'intraoral' | 'extraoral' | 'raio-x';
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface EvolucaoOrto {
|
||||
id: string;
|
||||
data: string;
|
||||
tipo: TipoEventoOrto;
|
||||
procedimento: string; // resumo textual (compat legado)
|
||||
proximoRetorno: string;
|
||||
fio?: string;
|
||||
elastico?: string;
|
||||
procedimentos?: string[]; // IPR, Recolagem, Torque, Profilaxia
|
||||
higiene?: number; // 1-10
|
||||
cooperacao?: CooperacaoOrto;
|
||||
observacoes?: string;
|
||||
}
|
||||
|
||||
export interface TratamentoOrto {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
pacienteNome: string;
|
||||
dataNascimento?: string;
|
||||
responsavel?: string;
|
||||
tipoPaciente?: TipoPacienteOrto;
|
||||
// Triagem & Diagnóstico
|
||||
triagem?: TriagemOrto;
|
||||
diagnostico?: DiagnosticoOrto;
|
||||
// Plano de tratamento
|
||||
dataInicio: string;
|
||||
tipoAparelho: string;
|
||||
extracoes?: string;
|
||||
sequenciaFios?: string;
|
||||
elasticos?: string;
|
||||
iprPlanejado?: string;
|
||||
tempoEstimado?: string;
|
||||
valor?: number;
|
||||
formaPagamento?: string;
|
||||
// Dashboard
|
||||
faseAtual?: string;
|
||||
fioAtual?: string;
|
||||
classificacao?: string;
|
||||
cooperacao?: CooperacaoOrto;
|
||||
percentualConcluido?: number;
|
||||
ultimaConsulta?: string;
|
||||
proximaConsulta?: string;
|
||||
mesesRestantes?: number;
|
||||
// Status & Contenção
|
||||
status: StatusOrto;
|
||||
contencaoTipo?: string;
|
||||
contencaoSuperior?: boolean;
|
||||
contencaoInferior?: boolean;
|
||||
tempoUso?: string;
|
||||
// Coleções
|
||||
evolucoes: EvolucaoOrto[];
|
||||
flags?: OrthoFlag[];
|
||||
fotos?: OrthoFoto[];
|
||||
}
|
||||
|
||||
export interface FinanceiroItem {
|
||||
id: string;
|
||||
pacienteNome: string;
|
||||
descricao: string;
|
||||
valor: number;
|
||||
dataVencimento: string;
|
||||
status: 'Pago' | 'Pendente' | 'Atrasado';
|
||||
formaPagamento: 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro';
|
||||
}
|
||||
|
||||
export interface Plano {
|
||||
id: string;
|
||||
nome: string;
|
||||
tipo: 'Convenio' | 'Particular' | 'Parceria';
|
||||
descontoPadrao: number; // Porcentagem
|
||||
corCartao: string;
|
||||
}
|
||||
|
||||
export interface Especialidade {
|
||||
id: string;
|
||||
nome: string;
|
||||
descricao: string;
|
||||
ordem: number;
|
||||
}
|
||||
|
||||
export interface ProcedimentoValorPlano {
|
||||
planoId: string;
|
||||
planoNome: string;
|
||||
valor: number;
|
||||
codigoPlano: string; // Código específico na operadora
|
||||
}
|
||||
|
||||
export interface Procedimento {
|
||||
id: string;
|
||||
nome: string;
|
||||
codigo?: string;
|
||||
codigoInterno?: string;
|
||||
descricao?: string;
|
||||
especialidadeId: string;
|
||||
especialidadeNome: string;
|
||||
valorParticular: number;
|
||||
categoria: 'Particular' | 'Plano' | 'Convênio' | 'Outros';
|
||||
ordem: number;
|
||||
tipo_regiao: 'DENTE' | 'ARCO' | 'GERAL';
|
||||
exige_face: boolean;
|
||||
valoresPlanos?: ProcedimentoValorPlano[];
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: string;
|
||||
nome: string;
|
||||
sobrenome: string;
|
||||
cpf: string;
|
||||
whatsapp: string;
|
||||
tipoInteresse: 'Particular' | 'Plano';
|
||||
dataCadastro: string;
|
||||
status: 'Novo' | 'Convertido' | 'Arquivado';
|
||||
}
|
||||
|
||||
export interface Notificacao {
|
||||
id: string;
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
tipo: 'lead' | 'sistema' | 'agenda' | 'financeiro';
|
||||
lida: boolean;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
clinicEmail?: string;
|
||||
clinicCalendarId?: string;
|
||||
googleApiKey?: string;
|
||||
googleCalendarIds?: Record<string, string>; // dentistId -> calendarId
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity } from 'lucide-react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Dentista, Especialidade, Plano, Procedimento, ProcedimentoValorPlano, DentistaHorario } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
|
||||
|
||||
const AdminSection: React.FC<{ title: string; buttonLabel: string; onButtonClick: () => void; children: React.ReactNode }> =
|
||||
({ title, buttonLabel, onButtonClick, children }) => (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title}>
|
||||
<button
|
||||
onClick={onButtonClick}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 font-bold text-xs uppercase transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={18} /> {buttonLabel}
|
||||
</button>
|
||||
</PageHeader>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DentistHoursModal: React.FC<{ dentist: Dentista; clinicaId: string; onClose: () => void }> = ({ dentist, clinicaId, onClose }) => {
|
||||
const { data: horarios, refresh } = useHybridBackend<DentistaHorario[]>(() => HybridBackend.getHorariosByDentista(dentist.id, clinicaId));
|
||||
const toast = useToast();
|
||||
const dias = ['DOMINGO', 'SEGUNDA', 'TERÇA', 'QUARTA', 'QUINTA', 'SEXTA', 'SÁBADO'];
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
await HybridBackend.saveHorario({
|
||||
dentista_id: dentist.id,
|
||||
clinica_id: clinicaId,
|
||||
dia_semana: parseInt(fd.get('dia') as string),
|
||||
hora_inicio: fd.get('inicio') as string,
|
||||
hora_fim: fd.get('fim') as string,
|
||||
ativo: true
|
||||
});
|
||||
toast.success("HORÁRIO ADICIONADO!");
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-gray-900 uppercase leading-tight">ESCALA DE ATENDIMENTO</h3>
|
||||
<p className="text-[10px] text-blue-600 font-bold uppercase">{dentist.nome}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-xl transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-3 bg-blue-50/50 p-4 rounded-xl border border-blue-100">
|
||||
<select name="dia" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold uppercase" required>
|
||||
{dias.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
<input name="inicio" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
||||
<input name="fim" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
||||
<button type="submit" className="bg-blue-600 text-white rounded-lg font-black text-[10px] uppercase hover:bg-blue-700 transition-all flex items-center justify-center gap-2">
|
||||
<Plus size={14} /> ADICIONAR
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||
{horarios?.map(h => (
|
||||
<div key={h.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm text-blue-600 border border-gray-100 font-black text-[10px]">
|
||||
{dias[h.dia_semana].substring(0, 3)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-black text-gray-700">
|
||||
<Clock size={14} className="text-gray-400" />
|
||||
<span>{h.hora_inicio.substring(0, 5)}</span>
|
||||
<ArrowRight size={12} className="text-gray-300" />
|
||||
<span>{h.hora_fim.substring(0, 5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => { await HybridBackend.deleteHorario(h.id); refresh(); toast.success("REMOVIDO!"); }}
|
||||
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- DENTISTAS ---
|
||||
export const DentistasView: React.FC = () => {
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const { data: dentistas, isLoading, error, refresh } = useHybridBackend<Dentista[]>(() => HybridBackend.getDentistas(activeWorkspace?.id));
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingDentista, setEditingDentista] = useState<Partial<Dentista> | null>(null);
|
||||
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
|
||||
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingDentista && editingDentista.id) {
|
||||
await HybridBackend.updateDentista({ ...editingDentista, ...data } as Dentista);
|
||||
toast.success("DENTISTA ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.saveDentista({ ...data, ativo: true } as any);
|
||||
toast.success("DENTISTA CADASTRADO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR DENTISTA.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE DENTISTA?")) {
|
||||
await HybridBackend.deleteDentista(id);
|
||||
toast.success("Dentista excluído.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const handleInviteDentist = async (d: Dentista) => {
|
||||
try {
|
||||
const activeW = HybridBackend.getActiveWorkspace();
|
||||
const res = await fetch('http://localhost:3005/api/dentistas/magic-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clinicaId: activeW.id, dentistName: d.nome })
|
||||
});
|
||||
const { link } = await res.json();
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopiedId(d.id);
|
||||
toast.success("LINK MÁGICO COPIADO!");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
toast.error("ERRO AO GERAR LINK.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="DENTISTAS & ESPECIALISTAS" buttonLabel="NOVO DENTISTA" onButtonClick={() => { setEditingDentista({}); setIsModalOpen(true); }}>
|
||||
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
||||
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{dentistas?.map(d => (
|
||||
<div key={d.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
||||
<div className="p-5 flex-grow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 uppercase">{d.nome}</h3>
|
||||
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full uppercase">{d.especialidade}</span>
|
||||
</div>
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white shadow" style={{ backgroundColor: d.corAgenda }}></div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 text-xs text-gray-500">
|
||||
<p className="flex items-center gap-2 font-medium"><Mail size={14} /> {d.email}</p>
|
||||
<p className="flex items-center gap-2 font-medium"><Phone size={14} /> {d.telefone}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-gray-50 pt-4 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<GoogleConnectButton
|
||||
ownerId={d.id}
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === d.id)}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInviteDentist(d)}
|
||||
title="COMPARTILHAR LINK DE CADASTRO"
|
||||
className={`p-2.5 rounded-xl border transition-all ${copiedId === d.id ? 'bg-green-50 text-green-600 border-green-200' : 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-100'}`}
|
||||
>
|
||||
{copiedId === d.id ? <Check size={18} /> : <Share2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 p-2 grid grid-cols-3 gap-1">
|
||||
<button onClick={() => { setEditingDentista(d); setIsModalOpen(true); }} className="text-[10px] uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-1"><Edit size={12} /> EDITAR</button>
|
||||
<button onClick={() => { setSelectedDentist(d); setIsHoursModalOpen(true); }} className="text-[10px] uppercase font-bold text-blue-600 hover:bg-blue-50 py-2 rounded-md flex items-center justify-center gap-1"><Clock size={12} /> ESCALA</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="text-[10px] uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-1"><Trash2 size={12} /> EXCLUIR</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingDentista?.id ? 'EDITAR' : 'NOVO'} DENTISTA</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input id="dentista-telefone" type="text" name="telefone" placeholder="TELEFONE" defaultValue={editingDentista?.telefone} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<input id="dentista-celular" type="text" name="celular" placeholder="WHATSAPP / CELULAR" defaultValue={editingDentista?.celular} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input id="dentista-cro" type="text" name="cro" placeholder="NÚMERO CRO" defaultValue={editingDentista?.cro} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<select id="dentista-cro-uf" name="cro_uf" defaultValue={editingDentista?.cro_uf || 'MS'} className="w-full border border-gray-300 rounded-lg p-2">
|
||||
<option value="MS">MS</option><option value="SP">SP</option><option value="RJ">RJ</option><option value="MG">MG</option><option value="PR">PR</option><option value="SC">SC</option>
|
||||
</select>
|
||||
</div>
|
||||
<input id="dentista-especialidade" type="text" name="especialidade" placeholder="ESPECIALIDADE PRINCIPAL" defaultValue={editingDentista?.especialidade} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<div>
|
||||
<label htmlFor="dentista-cor" className="text-xs font-bold text-gray-500 uppercase">COR NA AGENDA</label>
|
||||
<input id="dentista-cor" type="color" name="corAgenda" defaultValue={editingDentista?.corAgenda || '#3B82F6'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHoursModalOpen && selectedDentist && activeWorkspace && (
|
||||
<DentistHoursModal
|
||||
dentist={selectedDentist}
|
||||
clinicaId={activeWorkspace.id}
|
||||
onClose={() => { setIsHoursModalOpen(false); setSelectedDentist(null); }}
|
||||
/>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
// --- ESPECIALIDADES ---
|
||||
export const EspecialidadesView: React.FC = () => {
|
||||
const { data: especialidades, isLoading, error, refresh } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEsp, setEditingEsp] = useState<Partial<Especialidade> | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingEsp && editingEsp.id) {
|
||||
await HybridBackend.updateEspecialidade({ ...editingEsp, ...data } as Especialidade);
|
||||
toast.success("ESPECIALIDADE ATUALIZADA!");
|
||||
} else {
|
||||
await HybridBackend.saveEspecialidade(data as any);
|
||||
toast.success("ESPECIALIDADE SALVA!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR ESPECIALIDADE.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTA ESPECIALIDADE?")) {
|
||||
await HybridBackend.deleteEspecialidade(id);
|
||||
toast.success("Especialidade excluída.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !especialidades) return;
|
||||
|
||||
const items = Array.from(especialidades);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderEspecialidades(updatedOrders);
|
||||
toast.success("ORDEM ATUALIZADA!");
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="ESPECIALIDADES CLÍNICAS" buttonLabel="NOVA ESPECIALIDADE" onButtonClick={() => { setEditingEsp({}); setIsModalOpen(true); }}>
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left w-10"></th>
|
||||
<th className="px-6 py-3 text-left">NOME</th>
|
||||
<th className="px-6 py-3 text-left">DESCRIÇÃO</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="especialidades">
|
||||
{(provided) => (
|
||||
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
||||
{isLoading && <tr><td colSpan={4} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
||||
{error && <tr><td colSpan={4} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
||||
{especialidades?.map((esp, index) => (
|
||||
<Draggable key={esp.id} draggableId={esp.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<tr
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} transition-shadow`}
|
||||
>
|
||||
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
||||
<GripVertical size={18} />
|
||||
</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">{esp.nome}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{esp.descricao}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setEditingEsp(esp); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
||||
<button onClick={() => handleDelete(esp.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingEsp?.id ? 'EDITAR' : 'NOVA'} ESPECIALIDADE</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input id="esp-nome" name="nome" placeholder="NOME DA ESPECIALIDADE" defaultValue={editingEsp?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<textarea id="esp-desc" name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingEsp?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-24 uppercase-textarea"></textarea>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
// --- PROCEDIMENTOS ---
|
||||
export const ProcedimentosView: React.FC = () => {
|
||||
const { data: procedimentos, isLoading, error, refresh } = useHybridBackend<Procedimento[]>(HybridBackend.getProcedimentos);
|
||||
const { data: especialidades } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
||||
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProcedimento, setEditingProcedimento] = useState<Partial<Procedimento> | null>(null);
|
||||
const [planValues, setPlanValues] = useState<Partial<ProcedimentoValorPlano>[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProcedimento) {
|
||||
setPlanValues(editingProcedimento.valoresPlanos || []);
|
||||
// Set initial category if new
|
||||
if (!editingProcedimento.id && !editingProcedimento.categoria) {
|
||||
setEditingProcedimento(prev => ({ ...prev, categoria: 'Particular' }));
|
||||
}
|
||||
}
|
||||
}, [editingProcedimento]);
|
||||
|
||||
const suggestInternalCode = () => {
|
||||
const nextId = (procedimentos?.length || 0) + 1;
|
||||
setEditingProcedimento(prev => ({ ...prev, codigoInterno: nextId.toString() }));
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data: any = Object.fromEntries(formData.entries());
|
||||
|
||||
const esp = especialidades?.find(es => es.id === data.especialidadeId);
|
||||
|
||||
const payload: Partial<Procedimento> = {
|
||||
id: editingProcedimento?.id,
|
||||
nome: data.nome,
|
||||
codigo: data.codigo,
|
||||
codigoInterno: data.codigoInterno,
|
||||
categoria: data.categoria,
|
||||
descricao: data.descricao,
|
||||
especialidadeId: data.especialidadeId,
|
||||
especialidadeNome: esp?.nome || '',
|
||||
valorParticular: parseFloat(data.valorParticular),
|
||||
tipo_regiao: editingProcedimento?.tipo_regiao || 'GERAL',
|
||||
exige_face: editingProcedimento?.tipo_regiao === 'DENTE' ? !!editingProcedimento?.exige_face : false,
|
||||
valoresPlanos: planValues.map(pv => ({
|
||||
planoId: pv.planoId!,
|
||||
planoNome: planos?.find(p => p.id === pv.planoId)?.nome || '',
|
||||
valor: parseFloat(pv.valor as any),
|
||||
codigoPlano: pv.codigoPlano || ''
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (payload.id) {
|
||||
await HybridBackend.updateProcedimento(payload as Procedimento);
|
||||
toast.success("PROCEDIMENTO ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.saveProcedimento(payload);
|
||||
toast.success("PROCEDIMENTO SALVO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR PROCEDIMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PROCEDIMENTO?")) {
|
||||
await HybridBackend.deleteProcedimento(id);
|
||||
toast.success("Procedimento excluído.");
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPlanValue = () => setPlanValues(prev => [...prev, { planoId: '', valor: 0, codigoPlano: '' }]);
|
||||
const handleRemovePlanValue = (index: number) => setPlanValues(prev => prev.filter((_, i) => i !== index));
|
||||
const handlePlanValueChange = (index: number, field: 'planoId' | 'valor' | 'codigoPlano', value: string) => {
|
||||
const newValues = [...planValues];
|
||||
newValues[index] = { ...newValues[index], [field]: value };
|
||||
setPlanValues(newValues);
|
||||
};
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !procedimentos) return;
|
||||
|
||||
const items = Array.from(procedimentos);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderProcedimentos(updatedOrders);
|
||||
toast.success("ORDEM DOS PROCEDIMENTOS ATUALIZADA!");
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR PROCEDIMENTOS.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="PROCEDIMENTOS E VALORES" buttonLabel="NOVO PROCEDIMENTO" onButtonClick={() => { setEditingProcedimento({}); setIsModalOpen(true); }}>
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left w-10"></th>
|
||||
<th className="px-6 py-3 text-left">NOME</th>
|
||||
<th className="px-6 py-3 text-left">CÓDIGO (TUSS/INT)</th>
|
||||
<th className="px-6 py-3 text-left">CATEGORIA</th>
|
||||
<th className="px-6 py-3 text-left">ESPECIALIDADE</th>
|
||||
<th className="px-6 py-3 text-left">VALOR PARTICULAR</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="procedimentos">
|
||||
{(provided) => (
|
||||
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
||||
{isLoading && <tr><td colSpan={7} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
||||
{error && <tr><td colSpan={7} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
||||
{procedimentos?.map((proc, index) => (
|
||||
<Draggable key={proc.id} draggableId={proc.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<tr
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} transition-shadow`}
|
||||
>
|
||||
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
||||
<GripVertical size={18} />
|
||||
</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">{proc.nome}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">TUSS: {proc.codigo || '---'}</span>
|
||||
<span className="text-[10px] font-bold text-blue-600 uppercase bg-blue-50 px-1.5 py-0.5 rounded w-fit">INT: {proc.codigoInterno || '---'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="text-xs font-bold px-2 py-1 rounded-full bg-gray-100 text-gray-600 uppercase">{proc.categoria}</span></td>
|
||||
<td className="px-6 py-4 text-gray-600">{proc.especialidadeNome}</td>
|
||||
<td className="px-6 py-4 font-semibold text-gray-700">R$ {Number(proc.valorParticular).toFixed(2)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setEditingProcedimento(proc); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
||||
<button onClick={() => handleDelete(proc.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingProcedimento?.id ? 'EDITAR' : 'NOVO'} PROCEDIMENTO</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto pb-4">
|
||||
<input name="nome" placeholder="NOME DO PROCEDIMENTO" defaultValue={editingProcedimento?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CATEGORIA</label>
|
||||
<select
|
||||
name="categoria"
|
||||
value={editingProcedimento?.categoria || 'Particular'}
|
||||
onChange={(e) => {
|
||||
const cat = e.target.value as any;
|
||||
setEditingProcedimento(prev => ({ ...prev, categoria: cat }));
|
||||
if (cat === 'Particular' && !editingProcedimento?.codigoInterno) {
|
||||
suggestInternalCode();
|
||||
}
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select"
|
||||
required
|
||||
>
|
||||
<option value="Particular">Particular</option>
|
||||
<option value="Plano">Plano</option>
|
||||
<option value="Convênio">Convênio</option>
|
||||
<option value="Outros">Outros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CÓDIGO TUSS</label>
|
||||
<input
|
||||
name="codigo"
|
||||
placeholder={editingProcedimento?.categoria === 'Plano' ? "00000000" : "OPCIONAL"}
|
||||
defaultValue={editingProcedimento?.codigo}
|
||||
className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria !== 'Plano' ? 'bg-gray-50' : ''}`}
|
||||
required={editingProcedimento?.categoria === 'Plano'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase flex justify-between">
|
||||
CÓDIGO INTERNO
|
||||
{editingProcedimento?.categoria !== 'Particular' && (
|
||||
<button type="button" onClick={suggestInternalCode} className="text-blue-600 hover:text-blue-800">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
name="codigoInterno"
|
||||
placeholder={editingProcedimento?.categoria === 'Particular' ? "AUTO (CONTAGEM + 1)" : "EX: 101"}
|
||||
value={editingProcedimento?.codigoInterno || ''}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, codigoInterno: e.target.value }))}
|
||||
className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria === 'Particular' ? 'bg-gray-50 text-blue-600 font-bold' : ''}`}
|
||||
required={editingProcedimento?.categoria !== 'Particular'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<textarea name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingProcedimento?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-20 uppercase-textarea"></textarea>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">ESPECIALIDADE</label>
|
||||
<select name="especialidadeId" defaultValue={editingProcedimento?.especialidadeId} className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select" required>
|
||||
<option value="">SELECIONE UMA ESPECIALIDADE</option>
|
||||
{especialidades?.map(e => <option key={e.id} value={e.id}>{e.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">VALOR PARTICULAR (R$)</label>
|
||||
<input type="number" step="0.01" name="valorParticular" placeholder="0.00" defaultValue={editingProcedimento?.valorParticular} className="w-full border border-gray-300 rounded-lg p-2" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl border border-blue-100 space-y-4">
|
||||
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-widest flex items-center gap-2">
|
||||
<Activity size={12} /> REGRA DO ODONTOGRAMA (GTO)
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-gray-700 uppercase">EXIGÊNCIA DE REGIÃO NO LANÇAMENTO</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'GERAL', label: 'PROCED. GERAL', desc: 'SEM ODONTOGRAMA' },
|
||||
{ id: 'DENTE', label: 'DENTE ESPECÍFICO', desc: 'LIBERA NÚMEROS' },
|
||||
{ id: 'ARCO', label: 'ARCO COMPLETO', desc: 'LIBERA AI / AS' }
|
||||
].map((opt) => (
|
||||
<label
|
||||
key={opt.id}
|
||||
className={`
|
||||
relative flex flex-col p-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id
|
||||
? 'border-blue-600 bg-blue-50 ring-2 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tipo_regiao"
|
||||
value={opt.id}
|
||||
checked={(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, tipo_regiao: e.target.value as any }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-[10px] font-black uppercase text-gray-900 leading-tight">{opt.label}</span>
|
||||
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">{opt.desc}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(editingProcedimento?.tipo_regiao === 'DENTE') && (
|
||||
<div className="pt-2 animate-in slide-in-from-top-2 duration-200">
|
||||
<label className="flex items-center gap-3 p-3 bg-white rounded-lg border border-blue-200 cursor-pointer hover:bg-blue-50/50 transition-colors">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="exige_face"
|
||||
checked={!!editingProcedimento?.exige_face}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, exige_face: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-black text-gray-800 uppercase">Exigir preenchimento de Face?</span>
|
||||
<span className="text-[9px] font-bold text-gray-400 uppercase">(O, M, D, V, L, P) será obrigatório na GTO</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<h4 className="font-bold text-gray-700 uppercase mb-2">VALORES PARA PLANOS / CONVÊNIOS</h4>
|
||||
<div className="space-y-2">
|
||||
{planValues.map((pv, index) => (
|
||||
<div key={index} className="flex flex-col md:flex-row items-center gap-2 bg-gray-50 p-2 rounded-lg border">
|
||||
<select value={pv.planoId} onChange={(e) => handlePlanValueChange(index, 'planoId', e.target.value)} className="flex-1 border border-gray-300 rounded-lg p-2 bg-white uppercase-select">
|
||||
<option value="">SELECIONE UM PLANO</option>
|
||||
{planos?.filter(p => p.tipo !== 'Particular').map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
<input type="number" step="0.01" placeholder="VALOR (R$)" value={pv.valor} onChange={(e) => handlePlanValueChange(index, 'valor', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2" />
|
||||
<input type="text" placeholder="CÓD. PLANO" value={pv.codigoPlano} onChange={(e) => handlePlanValueChange(index, 'codigoPlano', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<button type="button" onClick={() => handleRemovePlanValue(index)} className="p-2 text-red-500 hover:bg-red-100 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={handleAddPlanValue} className="mt-2 text-xs font-bold text-blue-600 hover:underline uppercase flex items-center gap-1"><Plus size={14} /> ADICIONAR VALOR DE PLANO</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex-shrink-0">
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-4 rounded-xl font-black hover:bg-green-700 uppercase text-xs shadow-lg shadow-green-100 transition-all active:scale-[0.98]">
|
||||
SALVAR PROCEDIMENTO
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- PLANOS ---
|
||||
export const PlanosView: React.FC = () => {
|
||||
const { data: planos, isLoading, error, refresh } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingPlano, setEditingPlano] = useState<Partial<Plano> | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingPlano && editingPlano.id) {
|
||||
await HybridBackend.updatePlano({ ...editingPlano, ...data } as Plano);
|
||||
toast.success("PLANO ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.savePlano(data as any);
|
||||
toast.success("PLANO SALVO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR PLANO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PLANO?")) {
|
||||
await HybridBackend.deletePlano(id);
|
||||
toast.success("Plano excluído.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSection title="PLANOS E CONVÊNIOS" buttonLabel="NOVO PLANO" onButtonClick={() => { setEditingPlano({}); setIsModalOpen(true); }}>
|
||||
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
||||
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{planos?.map(p => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col overflow-hidden" style={{ borderTop: `4px solid ${p.corCartao}` }}>
|
||||
<div className="p-5 flex-grow">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-gray-900 uppercase">{p.nome}</h3>
|
||||
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full uppercase">{p.tipo}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">DESCONTO PADRÃO: <span className="font-bold">{p.descontoPadrao}%</span></p>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 p-2 flex gap-2 bg-gray-50/50">
|
||||
<button onClick={() => { setEditingPlano(p); setIsModalOpen(true); }} className="flex-1 text-xs uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-2"><Edit size={14} /> EDITAR</button>
|
||||
<button onClick={() => handleDelete(p.id)} className="flex-1 text-xs uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-2"><Trash2 size={14} /> EXCLUIR</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingPlano?.id ? 'EDITAR' : 'NOVO'} PLANO</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input name="nome" placeholder="NOME DO PLANO/CONVÊNIO" defaultValue={editingPlano?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<select name="tipo" defaultValue={editingPlano?.tipo || 'Convenio'} className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select" required>
|
||||
<option>Convenio</option>
|
||||
<option>Particular</option>
|
||||
<option>Parceria</option>
|
||||
</select>
|
||||
<input type="number" name="descontoPadrao" placeholder="DESCONTO PADRÃO (%)" defaultValue={editingPlano?.descontoPadrao} className="w-full border border-gray-300 rounded-lg p-2" />
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">COR DO CARTÃO</label>
|
||||
<input type="color" name="corCartao" defaultValue={editingPlano?.corCartao || '#1f2937'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- SYNC ---
|
||||
export const SyncView: React.FC = () => {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const startSync = async () => {
|
||||
setSyncing(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
await HybridBackend.syncGoogleSheetsToMysql((log) => {
|
||||
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${log}`]);
|
||||
});
|
||||
toast.success("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!");
|
||||
} catch {
|
||||
toast.error("FALHA NA SINCRONIZAÇÃO.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<PageHeader title="IMPORTAÇÃO E SINCRONIZAÇÃO" description="GOOGLE SHEETS ➔ MYSQL (SIS_ODONTO)">
|
||||
<button
|
||||
onClick={startSync}
|
||||
disabled={syncing}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-bold uppercase transition-colors shadow-sm ${syncing ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
|
||||
>
|
||||
<RefreshCw size={18} className={syncing ? 'animate-spin' : ''} />
|
||||
{syncing ? 'SINCRONIZANDO...' : 'INICIAR IMPORTAÇÃO'}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm text-green-400 h-96 overflow-y-auto shadow-inner uppercase">
|
||||
{logs.length === 0 && <span className="text-gray-500 opacity-50">AGUARDANDO INÍCIO DO PROCESSO...</span>}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="mb-1 border-l-2 border-green-700 pl-2 animate-in fade-in">{log}</div>
|
||||
))}
|
||||
{syncing && <div className="animate-pulse">_</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 border-t border-yellow-100 flex items-start gap-3">
|
||||
<div className="text-yellow-600 mt-0.5"><Database size={18} /></div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-yellow-800 uppercase">NOTA DE SEGURANÇA</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1 uppercase font-medium">
|
||||
ESTA AÇÃO LÊ TODOS OS DADOS DA PLANILHA MESTRE E ATUALIZA O BANCO DE DADOS MYSQL LOCAL.
|
||||
O GOOGLE SHEETS PERMANECE COMO FONTE SEGURA DE BACKUP. CONFLITOS SERÃO RESOLVIDOS PRIORIZANDO A DATA DE MODIFICAÇÃO MAIS RECENTE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// --- Reusable Debounce Hook ---
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// --- Advanced Appointment Modal Component ---
|
||||
const AppointmentModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
dentists: Dentista[] | null;
|
||||
initialDate?: string;
|
||||
initialAppointment?: Agendamento | null;
|
||||
}> = ({ isOpen, onClose, onSave, dentists, initialDate, initialAppointment }) => {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [duration, setDuration] = useState(15);
|
||||
const [selectedDate, setSelectedDate] = useState(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]);
|
||||
const [selectedTime, setSelectedTime] = useState(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : '');
|
||||
const [selectedDentistId, setSelectedDentistId] = useState<string | undefined>(dentists?.[0]?.id);
|
||||
const [procedimento, setProcedimento] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||
|
||||
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedSearchTerm), [debouncedSearchTerm]);
|
||||
const { data: searchResults, isLoading: isSearching } = useHybridBackend<Paciente[]>(fetcher, [debouncedSearchTerm]);
|
||||
|
||||
const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const { data: allHorarios } = useHybridBackend<DentistaHorario[]>(() =>
|
||||
selectedDentistId && activeWorkspace ? HybridBackend.getHorariosByDentista(selectedDentistId, activeWorkspace.id) : Promise.resolve([]),
|
||||
[selectedDentistId, activeWorkspace?.id]
|
||||
);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialAppointment) {
|
||||
setSelectedPatient({ nome: initialAppointment.pacienteNome, cpf: '', id: 'temp' } as any);
|
||||
const startDate = new Date(initialAppointment.start);
|
||||
const endDate = new Date(initialAppointment.end);
|
||||
setSelectedDate(startDate.toISOString().split('T')[0]);
|
||||
setSelectedTime(startDate.toTimeString().substring(0, 5));
|
||||
setDuration((endDate.getTime() - startDate.getTime()) / 60000);
|
||||
setSelectedDentistId(initialAppointment.dentistaId);
|
||||
setProcedimento(initialAppointment.procedimento);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
setSearchTerm('');
|
||||
setSelectedDate(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : '');
|
||||
setProcedimento('');
|
||||
setDuration(15);
|
||||
if (dentists && dentists.length > 0) {
|
||||
setSelectedDentistId(dentists[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialDate, dentists, initialAppointment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const selectedDentist = dentists?.find(d => d.id === selectedDentistId);
|
||||
|
||||
const timeSlots = useCallback(() => {
|
||||
const slots: string[] = [];
|
||||
if (!selectedDentistId || !selectedDate) return slots;
|
||||
|
||||
const existingAppointments = agendamentos?.filter(ag =>
|
||||
ag.dentistaId === selectedDentistId &&
|
||||
new Date(ag.start).toISOString().split('T')[0] === selectedDate &&
|
||||
ag.id !== initialAppointment?.id
|
||||
).map(ag => ({
|
||||
start: new Date(ag.start).getHours() * 60 + new Date(ag.start).getMinutes(),
|
||||
end: new Date(ag.end).getHours() * 60 + new Date(ag.end).getMinutes(),
|
||||
})) || [];
|
||||
|
||||
const dayOfWeek = new Date(selectedDate).getUTCDay();
|
||||
const dayHorarios = allHorarios?.filter(h => h.dia_semana === dayOfWeek && h.ativo) || [];
|
||||
|
||||
if (dayHorarios.length === 0) {
|
||||
// Default 8-18 fallback if no hours set
|
||||
for (let time = 8 * 60; time < 18 * 60; time += 15) {
|
||||
const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end);
|
||||
if (!isOccupied) {
|
||||
const hour = Math.floor(time / 60), minute = time % 60;
|
||||
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const h of dayHorarios) {
|
||||
const [hStart, mStart] = h.hora_inicio.split(':').map(Number);
|
||||
const [hEnd, mEnd] = h.hora_fim.split(':').map(Number);
|
||||
for (let time = hStart * 60 + mStart; time < hEnd * 60 + mEnd; time += 15) {
|
||||
const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end);
|
||||
if (!isOccupied) {
|
||||
const hour = Math.floor(time / 60), minute = time % 60;
|
||||
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current time is not on the 15m grid (like 12:03), add it as a valid slot
|
||||
if (selectedTime && !slots.includes(selectedTime)) {
|
||||
slots.push(selectedTime);
|
||||
slots.sort();
|
||||
}
|
||||
|
||||
return slots;
|
||||
}, [selectedDentistId, selectedDate, agendamentos, initialAppointment, selectedTime, allHorarios])();
|
||||
|
||||
const handlePatientSelect = (patient: Paciente) => {
|
||||
setSelectedPatient(patient);
|
||||
setSearchTerm('');
|
||||
setIsSearchFocused(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedPatient || !selectedDate || !selectedTime || !selectedDentistId || !procedimento) {
|
||||
toast.error("PREENCHA TODOS OS CAMPOS PARA AGENDAR.");
|
||||
return;
|
||||
}
|
||||
const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + duration * 60000);
|
||||
try {
|
||||
const agData = {
|
||||
pacienteNome: selectedPatient.nome,
|
||||
dentistaId: selectedDentistId,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
status: initialAppointment?.status || 'Pendente',
|
||||
procedimento: procedimento.toUpperCase(),
|
||||
};
|
||||
|
||||
if (initialAppointment?.id) {
|
||||
await HybridBackend.atualizarAgendamento(initialAppointment.id, agData);
|
||||
toast.success(`AGENDAMENTO DE ${selectedPatient.nome} ATUALIZADO!`);
|
||||
} else {
|
||||
await HybridBackend.salvarAgendamento(agData);
|
||||
toast.success(`AGENDAMENTO PARA ${selectedPatient.nome} SALVO!`);
|
||||
}
|
||||
refreshAgendamentos();
|
||||
onSave();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR AGENDAMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-orange-50 to-white">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2 uppercase">
|
||||
<CalendarIcon size={22} className="text-orange-500" /> AGENDAR CONSULTA
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<div className="mb-6">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE</label>
|
||||
{!selectedPatient ? (
|
||||
<div className="relative mt-2">
|
||||
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value.toUpperCase())} onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} placeholder="DIGITE O NOME OU CPF..." className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base" />
|
||||
{isSearchFocused && debouncedSearchTerm && (
|
||||
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
||||
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
||||
{searchResults?.map(p => (
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">{p.nome} ({p.cpf})</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedPatient && (
|
||||
<div className="animate-in fade-in space-y-6 lg:space-y-0">
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-x-8 lg:gap-y-6 space-y-5 lg:space-y-0">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DENTISTA RESPONSÁVEL</label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<select value={selectedDentistId} onChange={e => setSelectedDentistId(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
||||
{dentists?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
<div className="w-7 h-7 rounded-full flex-shrink-0 border-2 border-white shadow-md" style={{ backgroundColor: selectedDentist?.corAgenda || '#ccc' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA</label>
|
||||
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)} className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DURAÇÃO</label>
|
||||
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
||||
<option value={15}>15 MIN</option>
|
||||
<option value={30}>30 MIN</option>
|
||||
<option value={45}>45 MIN</option>
|
||||
<option value={60}>60 MIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PROCEDIMENTO</label>
|
||||
<input type="text" value={procedimento} onChange={e => setProcedimento(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO, LIMPEZA..." className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 uppercase-input placeholder-gray-400 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">HORÁRIOS DISPONÍVEIS</label>
|
||||
<div className="grid grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2 mt-3">
|
||||
{timeSlots.map(slot => (
|
||||
<button type="button" key={slot} onClick={() => setSelectedTime(slot)} className={`py-2.5 lg:py-3 rounded-lg text-sm font-bold border transition-all duration-150 ${selectedTime === slot ? 'bg-blue-600 text-white border-blue-600 shadow-md scale-105' : 'bg-white text-gray-800 border-gray-300 hover:border-blue-400 hover:bg-blue-50'}`}>{slot}</button>
|
||||
))}
|
||||
{timeSlots.length === 0 && <p className="col-span-6 lg:col-span-8 xl:col-span-10 text-center text-sm text-gray-400 p-8 bg-gray-50 rounded-lg border border-dashed border-gray-200 uppercase font-bold">NENHUM HORÁRIO DISPONÍVEL.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
<button type="button" onClick={onClose} className="px-8 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" disabled={!selectedPatient} className="px-10 py-2.5 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg shadow-sm transition-colors text-sm uppercase disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"><CalendarIcon size={16} /> CONFIRMAR</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgendaView: React.FC = () => {
|
||||
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (agendamentos && dentists) {
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
const isDentist = currentRole === 'dentista';
|
||||
const loginedDentistId = dentists.find(d => d.email === currentUserEmail)?.id;
|
||||
|
||||
let filteredAgendamentos = agendamentos;
|
||||
if (isDentist) {
|
||||
filteredAgendamentos = agendamentos.filter(ag => ag.dentistaId === loginedDentistId);
|
||||
}
|
||||
|
||||
const mappedEvents = filteredAgendamentos.map(ag => {
|
||||
const dentist = dentists.find(d => d.id === ag.dentistaId);
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${ag.pacienteNome} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
backgroundColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
borderColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
};
|
||||
});
|
||||
|
||||
// Add Google events if any
|
||||
if (googleEvents && googleEvents.length > 0) {
|
||||
let filteredGoogleEvents = googleEvents;
|
||||
if (isDentist) {
|
||||
filteredGoogleEvents = googleEvents.filter(ev => ev.extendedProps.owner_id === currentUserEmail);
|
||||
}
|
||||
mappedEvents.push(...filteredGoogleEvents);
|
||||
}
|
||||
|
||||
setEvents(mappedEvents);
|
||||
}
|
||||
}, [agendamentos, dentists, googleEvents]);
|
||||
|
||||
const handleDateClick = (arg: any) => { setSelectedDateInfo(arg); setIsModalOpen(true); };
|
||||
const handleEventClick = (info: any) => {
|
||||
if (info.event.extendedProps.isGoogle) {
|
||||
toast.info(`EVENTO DO GOOGLE CALENDAR: ${info.event.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ag = info.event.extendedProps as Agendamento;
|
||||
setSelectedAppointment(ag);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: Agendamento['status']) => {
|
||||
try {
|
||||
await HybridBackend.atualizarAgendamento(id, { status });
|
||||
toast.success(`STATUS ATUALIZADO PARA ${status.toUpperCase()}`);
|
||||
refresh();
|
||||
setShowDetails(false);
|
||||
} catch {
|
||||
toast.error("ERRO AO ATUALIZAR STATUS.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAppointment = async (id: string) => {
|
||||
if (!confirm("DESEJA REALMENTE EXCLUIR ESTE AGENDAMENTO?")) return;
|
||||
try {
|
||||
await HybridBackend.excluirAgendamento(id);
|
||||
toast.success("AGENDAMENTO EXCLUÍDO!");
|
||||
refresh();
|
||||
setShowDetails(false);
|
||||
} catch {
|
||||
toast.error("ERRO AO EXCLUIR AGENDAMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointment = () => { refresh(); refreshGoogle(); setIsModalOpen(false); setSelectedAppointment(null); };
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !dentists) return;
|
||||
|
||||
const items = Array.from(dentists);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderDentistas(updatedOrders);
|
||||
toast.success("ORDEM DOS DENTISTAS ATUALIZADA!");
|
||||
refreshDentists();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR DENTISTAS.");
|
||||
}
|
||||
};
|
||||
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
|
||||
const filteredDentists = dentists?.filter(d => {
|
||||
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
||||
if (currentRole === 'dentista') return d.email === currentUserEmail;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="AGENDA CLÍNICA">
|
||||
<div className="flex gap-4 items-center">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="dentists-legend" direction="horizontal">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
|
||||
{filteredDentists?.map((d, index) => (
|
||||
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`flex items-center gap-1.5 text-[10px] text-gray-700 bg-white border border-gray-200 px-3 py-1.5 rounded-full uppercase font-bold shadow-sm transition-all ${currentRole !== 'dentista' ? 'cursor-grab active:cursor-grabbing hover:border-gray-300' : ''} ${snapshot.isDragging ? 'ring-2 ring-blue-500 scale-105 shadow-lg' : ''}`}
|
||||
>
|
||||
{currentRole !== 'dentista' && <GripVertical size={12} className="text-gray-400" />}
|
||||
<div className="w-2.5 h-2.5 rounded-full shadow-inner" style={{ backgroundColor: d.corAgenda }}></div>
|
||||
{d.nome}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
|
||||
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
|
||||
<GearIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-blue-700 shadow-sm transition-colors">
|
||||
<Plus size={16} /> NOVO AGENDAMENTO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 relative" style={{ height: '750px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
||||
locale="pt-br"
|
||||
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
|
||||
slotMinTime="08:00:00"
|
||||
slotMaxTime="19:00:00"
|
||||
allDaySlot={false}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||
eventContent={(eventInfo) => {
|
||||
const isGoogle = eventInfo.event.extendedProps.isGoogle;
|
||||
return (
|
||||
<div className="p-0.5 overflow-hidden uppercase h-full">
|
||||
<div className="text-[10px] font-bold">{eventInfo.timeText}</div>
|
||||
{isGoogle ? (
|
||||
<div className="text-[10px] font-black leading-tight break-words">{eventInfo.event.title}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs truncate font-bold">{eventInfo.event.extendedProps.pacienteNome}</div>
|
||||
<div className="text-[10px] truncate opacity-90">{eventInfo.event.extendedProps.procedimento}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Details "Popover" (Modal style for real estate) */}
|
||||
{showDetails && selectedAppointment && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100">
|
||||
{/* Header with quick actions */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gray-50/30">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-blue-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EDITAR"
|
||||
>
|
||||
<Edit size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAppointment(selectedAppointment.id)}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-red-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EXCLUIR"
|
||||
>
|
||||
<Trash2 size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setShowDetails(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-10 space-y-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-4 bg-blue-50 rounded-2xl">
|
||||
<User size={28} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-black text-gray-900 leading-none uppercase tracking-tighter">{selectedAppointment.pacienteNome}</h3>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-2 bg-gray-100 px-3 py-1 rounded-full w-fit">
|
||||
<Stethoscope size={12} className="text-blue-400" /> {selectedAppointment.procedimento}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 py-6 border-y border-gray-50">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Data e Hora</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<CalendarRange size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<Clock size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentists?.find(d => d.id === selectedAppointment.dentistaId)?.corAgenda }}></div>
|
||||
{(selectedAppointment as any).dentistaNome}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Action Buttons */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Status do Atendimento</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Faltou')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Faltou' ? 'bg-red-50 text-red-600 border-red-200' : 'bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-500'}`}
|
||||
>
|
||||
<AlertCircle size={16} /> Marcar Falta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Confirmado')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Confirmado' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : 'bg-white border-gray-100 text-gray-500 hover:border-emerald-200 hover:text-emerald-500'}`}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Reagendar */}
|
||||
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex gap-4">
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="flex-1 py-4 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
||||
>
|
||||
Reagendar Consulta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.animate-in { animation: animate-in 0.2s ease-out; }
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
`}</style>
|
||||
<AppointmentModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setSelectedAppointment(null); }}
|
||||
onSave={handleSaveAppointment}
|
||||
dentists={dentists}
|
||||
initialDate={selectedDateInfo?.dateStr}
|
||||
initialAppointment={selectedAppointment}
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Cores profissionais permitidas
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Azul Score', value: '#2563eb' },
|
||||
{ name: 'Verde Esmeralda', value: '#059669' },
|
||||
{ name: 'Indigo Premium', value: '#4f46e5' },
|
||||
{ name: 'Vinho Elegante', value: '#9f1239' },
|
||||
{ name: 'Slate Moderno', value: '#334155' },
|
||||
{ name: 'Laranja Vibrante', value: '#ea580c' },
|
||||
{ name: 'Teal Clínico', value: '#0d9488' },
|
||||
{ name: 'Roxo Nobre', value: '#7c3aed' },
|
||||
];
|
||||
|
||||
export const ClinicasView: React.FC = () => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const toast = useToast();
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
|
||||
|
||||
const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
|
||||
const [savingColor, setSavingColor] = useState(false);
|
||||
|
||||
const handleSwitch = (workspaceId: string) => {
|
||||
if (activeWorkspace?.id === workspaceId) {
|
||||
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
|
||||
return;
|
||||
}
|
||||
HybridBackend.switchWorkspace(workspaceId);
|
||||
toast.success("TROCANDO DE UNIDADE...");
|
||||
};
|
||||
|
||||
const handleUpdateColor = async (workspaceId: string, color: string) => {
|
||||
setSavingColor(true);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3005/api/clinicas/${workspaceId}/color`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cor: color })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success("COR DA UNIDADE ATUALIZADA!");
|
||||
|
||||
// Atualizar o localStorage local para refletir a mudança sem refresh imediato no objeto workspaces
|
||||
const updatedWorkspaces = workspaces.map(w => w.id === workspaceId ? { ...w, cor: color } : w);
|
||||
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(updatedWorkspaces));
|
||||
|
||||
// Se a clínica editada for a ativa, atualizamos ela também
|
||||
if (activeWorkspace?.id === workspaceId) {
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify({ ...activeWorkspace, cor: color }));
|
||||
}
|
||||
|
||||
setIsEditingColor(null);
|
||||
// Forçamos um reload após um pequeno delay para aplicar as cores globalmente via CSS/Variables se necessário
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error("ERRO AO SALVAR COR.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("ERRO DE CONEXÃO.");
|
||||
} finally {
|
||||
setSavingColor(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-500">
|
||||
<PageHeader
|
||||
title="GESTÃO DE UNIDADES"
|
||||
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{workspaces.map((ws) => {
|
||||
const isActive = activeWorkspace?.id === ws.id;
|
||||
const clinColor = ws.cor || '#2563eb';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`group relative bg-white rounded-[2.5rem] p-1 border-2 transition-all duration-500 ${isActive
|
||||
? 'shadow-[0_20px_50px_-15px_rgba(0,0,0,0.1)]'
|
||||
: 'border-transparent hover:border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)]'
|
||||
}`}
|
||||
style={{ borderColor: isActive ? clinColor : 'transparent' }}
|
||||
>
|
||||
<div className="px-8 py-10 rounded-[2.2rem] bg-white h-full flex flex-col">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div
|
||||
className={`w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 group-hover:scale-110 shadow-lg`}
|
||||
style={{
|
||||
backgroundColor: clinColor,
|
||||
color: '#fff',
|
||||
boxShadow: `0 10px 20px -5px ${clinColor}44`
|
||||
}}
|
||||
>
|
||||
<Building2 size={32} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{isActive && (
|
||||
<span
|
||||
className="text-white text-[9px] font-black px-4 py-1.5 rounded-full uppercase tracking-widest shadow-sm"
|
||||
style={{ backgroundColor: clinColor }}
|
||||
>
|
||||
ATIVA
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsEditingColor(isEditingColor === ws.id ? null : ws.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 bg-gray-50 rounded-xl transition-colors"
|
||||
title="Mudar Cor"
|
||||
>
|
||||
<Palette size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-grow">
|
||||
<h3 className="text-2xl font-black text-gray-900 uppercase tracking-tighter leading-tight">
|
||||
{ws.nome}
|
||||
</h3>
|
||||
|
||||
{isEditingColor === ws.id ? (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-2xl border border-gray-100 animate-in slide-in-from-top-2 duration-300">
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-3">Escolha a cor da marca</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => handleUpdateColor(ws.id, c.value)}
|
||||
disabled={savingColor}
|
||||
className={`w-full h-8 rounded-lg border-2 transition-all ${ws.cor === c.value ? 'border-white ring-2 ring-gray-300' : 'border-transparent hover:scale-110'}`}
|
||||
style={{ backgroundColor: c.value }}
|
||||
title={c.name}
|
||||
>
|
||||
{savingColor && ws.cor === c.value && <Loader2 size={12} className="animate-spin text-white mx-auto" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<MapPin size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Unidade Operacional</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-50 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: `${clinColor}11`, color: clinColor }}>
|
||||
<Shield size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest leading-none mb-1">Seu Perfil</p>
|
||||
<p className="text-xs font-bold text-gray-700 uppercase">{ws.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSwitch(ws.id)}
|
||||
disabled={isActive || isEditingColor === ws.id}
|
||||
className={`mt-10 w-full py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all duration-300 flex items-center justify-center gap-3 group/btn shadow-lg`}
|
||||
style={{
|
||||
backgroundColor: isActive ? '#f3f4f6' : clinColor,
|
||||
color: isActive ? '#9ca3af' : '#fff',
|
||||
boxShadow: isActive ? 'none' : `0 10px 20px -10px ${clinColor}66`
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<>UNIDADE ATUAL <BadgeCheck size={18} /></>
|
||||
) : (
|
||||
<>ACESSAR UNIDADE <ArrowRight size={18} className="group-hover/btn:translate-x-1 transition-transform" /></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] p-10 border border-gray-100 flex flex-col md:flex-row items-center justify-between gap-6 shadow-sm">
|
||||
<div className="text-center md:text-left">
|
||||
<h4 className="text-lg font-black text-gray-900 uppercase tracking-tight italic">Identidade Visual Dinâmica</h4>
|
||||
<p className="text-sm text-gray-500 font-medium">A cor escolhida será aplicada automaticamente em toda a interface desta clínica.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{PRESET_COLORS.slice(0, 4).map(c => (
|
||||
<div key={c.value} className="w-3 h-3 rounded-full" style={{ backgroundColor: c.value }}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Users,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Megaphone,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Plus,
|
||||
Clock,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { NotificationCenter } from '../components/NotificationCenter.tsx';
|
||||
|
||||
const StatCard: React.FC<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: string;
|
||||
isPositive?: boolean;
|
||||
color: 'blue' | 'green' | 'purple' | 'amber';
|
||||
onClick?: () => void;
|
||||
}> = ({ title, value, icon, trend, isPositive, color, onClick }) => {
|
||||
const bgLight = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
purple: 'bg-indigo-50 text-indigo-600',
|
||||
amber: 'bg-amber-50 text-amber-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-[2rem] p-8 border border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)] hover:shadow-[0_20px_50px_-20px_rgba(0,0,0,0.1)] transition-all duration-500 group relative overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 opacity-[0.03] -mr-8 -mt-8 rounded-full bg-current transform group-hover:scale-110 transition-transform duration-700"></div>
|
||||
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
<div className="space-y-4">
|
||||
<div className={`w-12 h-12 ${bgLight[color]} rounded-2xl flex items-center justify-center mb-2 group-hover:scale-110 transition-transform`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">{title}</p>
|
||||
<h3 className="text-3xl font-black text-gray-900 tracking-tight mt-1">{value}</h3>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1.5 text-xs font-bold ${isPositive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
<div className={`p-0.5 rounded-full ${isPositive ? 'bg-emerald-100' : 'bg-rose-100'}`}>
|
||||
{isPositive ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
</div>
|
||||
{trend}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const { data, isLoading } = useHybridBackend<any>(HybridBackend.getDashboardStats);
|
||||
|
||||
const growthData = useMemo(() => {
|
||||
if (!data?.growth || data.growth.length === 0) {
|
||||
return Array(6).fill(null).map((_, i) => ({
|
||||
label: `MÊS ${i + 1}`,
|
||||
receita: 0,
|
||||
despesa: 0,
|
||||
heightP: '0%',
|
||||
heightR: '0%'
|
||||
}));
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...data.growth.map((g: any) => Math.max(parseFloat(g.receita), parseFloat(g.despesa))), 1000);
|
||||
|
||||
return data.growth.map((g: any) => {
|
||||
const [year, month] = g.mes.split('-');
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||
const label = date.toLocaleString('pt-BR', { month: 'short' }).toUpperCase();
|
||||
|
||||
return {
|
||||
label,
|
||||
receita: parseFloat(g.receita),
|
||||
despesa: parseFloat(g.despesa),
|
||||
// Proporção para o gráfico
|
||||
heightR: `${(parseFloat(g.receita) / maxVal) * 100}%`,
|
||||
heightD: `${(parseFloat(g.despesa) / maxVal) * 100}%`
|
||||
};
|
||||
}).slice(-6); // Garantir últimos 6
|
||||
}, [data]);
|
||||
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const showFinancials = ['admin', 'donoclinica', 'funcionario'].includes(currentRole);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = data?.stats || {
|
||||
totalPacientes: 0,
|
||||
totalAgendamentos: 0,
|
||||
totalLeads: 0,
|
||||
faturamentoTotal: 0,
|
||||
despesasTotal: 0
|
||||
};
|
||||
|
||||
const recent = data?.recentAgendamentos || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<PageHeader
|
||||
title="DASHBOARD ANALÍTICO"
|
||||
description="Informações consolidadas em tempo real da sua clínica."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="bg-white text-gray-900 border border-gray-200 px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-gray-50 transition-all flex items-center gap-2 shadow-sm">
|
||||
<Clock size={16} /> Relatórios
|
||||
</button>
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') && (
|
||||
<button
|
||||
onClick={() => window.location.hash = '#/pacientes'}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all flex items-center gap-2 shadow-lg shadow-blue-100"
|
||||
>
|
||||
<Plus size={16} /> Novo Paciente
|
||||
</button>
|
||||
)}
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-${showFinancials ? '4' : '2'} gap-8`}>
|
||||
<StatCard
|
||||
title="Pacientes Ativos"
|
||||
value={stats.totalPacientes}
|
||||
icon={<Users size={24} />}
|
||||
trend="+12% este mês"
|
||||
isPositive={true}
|
||||
color="blue"
|
||||
onClick={() => window.location.hash = '#/pacientes'}
|
||||
/>
|
||||
{showFinancials && (
|
||||
<StatCard
|
||||
title="Faturamento Bruto"
|
||||
value={new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(stats.faturamentoTotal)}
|
||||
icon={<DollarSign size={24} />}
|
||||
trend="+5.4% vs mês ant."
|
||||
isPositive={true}
|
||||
color="green"
|
||||
onClick={() => window.location.hash = '#/financeiro'}
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
title="Agendamentos"
|
||||
value={stats.totalAgendamentos}
|
||||
icon={<Calendar size={24} />}
|
||||
trend="82% de ocupação"
|
||||
isPositive={true}
|
||||
color="purple"
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
/>
|
||||
{showFinancials && (
|
||||
<StatCard
|
||||
title="Novas Leads"
|
||||
value={stats.totalLeads}
|
||||
icon={<Megaphone size={24} />}
|
||||
trend="-2% vs ontem"
|
||||
isPositive={false}
|
||||
color="amber"
|
||||
onClick={() => window.location.hash = '#/leads'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`grid lg:grid-cols-${showFinancials ? '3' : '1'} gap-8`}>
|
||||
{/* Growth Chart Area */}
|
||||
{showFinancials && (
|
||||
<div className="lg:col-span-2 bg-white rounded-[2.5rem] p-10 border border-gray-100 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.03)]">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h4 className="text-xl font-black text-gray-900 uppercase tracking-tighter italic">Crescimento Mensal</h4>
|
||||
<p className="text-xs font-bold text-gray-400 mt-1 uppercase">Receitas vs Despesas Reais</p>
|
||||
</div>
|
||||
<select
|
||||
id="chart-period-dashboard"
|
||||
name="chart-period"
|
||||
className="bg-gray-50 border-none text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl focus:ring-2 focus:ring-blue-100 cursor-pointer"
|
||||
>
|
||||
<option value="6m">Últimos 6 meses</option>
|
||||
<option value="year">Este Ano</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-64 flex items-end justify-between gap-4 px-4">
|
||||
{growthData.map((d: any, i: number) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-3 group">
|
||||
<div className="w-full flex justify-center gap-1 items-end h-[200px] bg-slate-50/50 rounded-2xl p-1">
|
||||
{/* Barra Despesa */}
|
||||
<div
|
||||
title={`Despesa: R$ ${d.despesa}`}
|
||||
className="w-1/3 bg-rose-200 rounded-lg group-hover:bg-rose-300 transition-all duration-500 origin-bottom"
|
||||
style={{ height: d.heightD || '5%' }}
|
||||
></div>
|
||||
{/* Barra Receita */}
|
||||
<div
|
||||
title={`Receita: R$ ${d.receita}`}
|
||||
className="w-1/3 bg-blue-500 rounded-lg group-hover:bg-blue-600 shadow-sm group-hover:shadow-blue-200 transition-all duration-500 origin-bottom"
|
||||
style={{ height: d.heightR || '5%' }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{d.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-sm"></div>
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase">Receitas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-rose-200 rounded-sm"></div>
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase">Despesas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-gray-900 rounded-[2.5rem] p-10 shadow-2xl shadow-blue-200 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/20 blur-[80px] rounded-full"></div>
|
||||
<div className="relative z-10 h-full flex flex-col">
|
||||
<h4 className="text-xl font-black text-white uppercase tracking-tighter italic mb-8">Últimos Agendamentos</h4>
|
||||
|
||||
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
||||
{recent.length > 0 ? recent.map((app: any) => (
|
||||
<div
|
||||
key={app.id}
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
className="flex items-center gap-4 group cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center text-blue-400 group-hover:bg-blue-600 group-hover:text-white transition-all duration-300 flex-shrink-0">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight truncate">{app.pacienteNome || 'Paciente'}</div>
|
||||
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5 truncate">
|
||||
{app.dentistaNome || 'Dr. Dentista'} • {new Date(app.start_time).toLocaleDateString('pt-BR')} {new Date(app.start_time).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-40">
|
||||
<Calendar size={40} className="text-white mb-2" />
|
||||
<p className="text-white text-[10px] font-bold uppercase tracking-widest">Nenhum agendamento recente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
className="w-full mt-8 bg-white/5 border border-white/10 text-white py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-white/10 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
Ver Agenda Completa <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, Lock, User, Stethoscope, Phone, ShieldCheck, Mail, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export const DentistRegisterView: React.FC = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Get data from URL
|
||||
const query = new URLSearchParams(window.location.hash.split('?')[1]);
|
||||
const clinicaId = query.get('clinica');
|
||||
const tempName = query.get('nome');
|
||||
const token = query.get('token');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome: tempName || '',
|
||||
email: '',
|
||||
senha: '',
|
||||
cro: '',
|
||||
cro_uf: 'MS',
|
||||
celular: '',
|
||||
especialidade: ''
|
||||
});
|
||||
|
||||
if (!token || !clinicaId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-20 h-20 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto shadow-sm">
|
||||
<Lock size={40} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-gray-900 uppercase">LINK INVÁLIDO OU EXPIRADO</h2>
|
||||
<p className="text-gray-500 max-w-sm mx-auto font-medium">Por favor, solicite um novo convite ao administrador da clínica.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/dentistas/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
id: 'd_' + Math.random().toString(36).substring(2, 9),
|
||||
clinicaId
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success("CADASTRO REALIZADO COM SUCESSO!");
|
||||
setStep(3);
|
||||
} else {
|
||||
toast.error("ERRO AO REALIZAR CADASTRO.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("FALHA DE CONEXÃO.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center p-4">
|
||||
<div className="max-w-xl w-full">
|
||||
{/* Progress Header */}
|
||||
<div className="mb-10 text-center space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase tracking-widest border border-blue-100 mb-4">
|
||||
<ShieldCheck size={14} /> Convite de Profissional
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-gray-900 tracking-tighter uppercase leading-none">Complete seu Perfil</h1>
|
||||
<p className="text-gray-500 font-medium">Bem-vindo ao SCOREODONTO. Você está sendo convidado para a unidade: <span className="text-blue-600 font-bold uppercase">{clinicaId}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] shadow-[0_50px_100px_-20px_rgba(0,0,0,0.08)] border border-gray-100 overflow-hidden relative">
|
||||
<div className="h-2 bg-gray-100 w-full">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-700"
|
||||
style={{ width: `${(step / 3) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="p-10 animate-in fade-in slide-in-from-right-10 duration-500">
|
||||
<form className="space-y-6" onSubmit={(e) => { e.preventDefault(); setStep(2); }}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Seu Nome Profissional</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-blue-500 transition-colors" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.nome}
|
||||
onChange={e => setFormData({ ...formData, nome: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="NOME COMPLETO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Email de Acesso</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Senha de Acesso</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.senha}
|
||||
onChange={e => setFormData({ ...formData, senha: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gray-900 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-600 hover:shadow-2xl hover:shadow-blue-200 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
Próximo Passo <ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="p-10 animate-in fade-in slide-in-from-right-10 duration-500">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Inscrição CRO</label>
|
||||
<div className="relative group">
|
||||
<Database className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.cro}
|
||||
onChange={e => setFormData({ ...formData, cro: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="00000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Estado CRO</label>
|
||||
<select
|
||||
value={formData.cro_uf}
|
||||
onChange={e => setFormData({ ...formData, cro_uf: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-6 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
>
|
||||
<option>MS</option><option>SP</option><option>RJ</option><option>MG</option><option>PR</option><option>SC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Especialidade Principal</label>
|
||||
<div className="relative group">
|
||||
<Stethoscope className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.especialidade}
|
||||
onChange={e => setFormData({ ...formData, especialidade: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="EX: ORTODONTIA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">WhatsApp de Contato</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.celular}
|
||||
onChange={e => setFormData({ ...formData, celular: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 bg-gray-100 text-gray-600 py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-gray-200 transition-all font-bold"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-[2] bg-blue-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-700 shadow-xl shadow-blue-200 transition-all flex items-center justify-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <>Finalizar e Entrar <ArrowRight size={20} /></>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="p-16 text-center animate-in zoom-in duration-500">
|
||||
<div className="w-24 h-24 bg-green-50 text-green-500 rounded-[2rem] flex items-center justify-center mx-auto mb-8 shadow-inner ring-1 ring-green-100">
|
||||
<ShieldCheck size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-gray-900 uppercase tracking-tight mb-4">Tudo Pronto!</h2>
|
||||
<p className="text-gray-500 font-medium mb-10">Seu perfil foi criado e você já está vinculado à unidade <br /> <span className="text-blue-600 font-bold">{clinicaId}</span>.</p>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="w-full bg-gray-900 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-600 shadow-2xl transition-all"
|
||||
>
|
||||
Ir para o Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-gray-400 text-[10px] font-black uppercase tracking-widest">
|
||||
<ShieldCheck size={14} /> Ambiente Seguro e Auditorado por Administradores
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Filter, Plus, TrendingUp, Clock, TrendingDown, CheckCircle, Download, Trash2, X, DollarSign, Loader2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { FinanceiroItem, Paciente } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// --- Reusable Debounce Hook ---
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// ----------- NOVO LANÇAMENTO MODAL -----------
|
||||
const NovoLancamentoModal: React.FC<{
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<Paciente[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchTerm) { setSearchResults([]); return; }
|
||||
setIsSearching(true);
|
||||
HybridBackend.getPacientes(debouncedSearchTerm).then(results => {
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
});
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
const handlePatientSelect = (patient: Paciente) => {
|
||||
setSelectedPatient(patient);
|
||||
setSearchTerm('');
|
||||
setIsSearchFocused(false);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState({
|
||||
id: crypto.randomUUID(),
|
||||
descricao: '',
|
||||
valor: '',
|
||||
dataVencimento: new Date().toISOString().split('T')[0],
|
||||
status: 'Pendente' as 'Pago' | 'Pendente' | 'Atrasado',
|
||||
formaPagamento: 'Pix' as 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedPatient || !form.descricao || !form.valor) {
|
||||
toast.error('PREENCHA TODOS OS CAMPOS OBRIGATÓRIOS.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.saveFinanceiro({
|
||||
...form,
|
||||
pacienteNome: selectedPatient.nome,
|
||||
valor: parseFloat(form.valor),
|
||||
});
|
||||
toast.success('LANÇAMENTO SALVO COM SUCESSO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO SALVAR LANÇAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-3xl lg:w-[70vw] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
{/* HEADER */}
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-green-50 to-white">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<DollarSign size={22} className="text-green-600" /> NOVO LANÇAMENTO
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{/* BODY */}
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
{/* PACIENTE SEARCH */}
|
||||
<div className="mb-6">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE *</label>
|
||||
{!selectedPatient ? (
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value.toUpperCase())}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)}
|
||||
placeholder="DIGITE O NOME OU CPF..."
|
||||
className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base"
|
||||
/>
|
||||
{isSearchFocused && debouncedSearchTerm && (
|
||||
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
||||
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
||||
{searchResults.map(p => (
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">
|
||||
{p.nome} ({p.cpf})
|
||||
</li>
|
||||
))}
|
||||
{!isSearching && searchResults.length === 0 && <li className="p-3 text-sm text-gray-500">NENHUM PACIENTE ENCONTRADO.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:grid lg:grid-cols-2 lg:gap-x-8 lg:gap-y-5 space-y-5 lg:space-y-0">
|
||||
{/* DESCRIÇÃO */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DESCRIÇÃO *</label>
|
||||
<input type="text" required value={form.descricao}
|
||||
onChange={e => setForm(f => ({ ...f, descricao: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 uppercase-input text-sm" placeholder="EX: CONSULTA AVALIAÇÃO, LIMPEZA..." />
|
||||
</div>
|
||||
{/* VALOR */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">VALOR (R$) *</label>
|
||||
<input type="number" step="0.01" required value={form.valor}
|
||||
onChange={e => setForm(f => ({ ...f, valor: e.target.value }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" placeholder="0,00" />
|
||||
</div>
|
||||
{/* VENCIMENTO */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA VENCIMENTO</label>
|
||||
<input type="date" value={form.dataVencimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataVencimento: e.target.value }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" />
|
||||
</div>
|
||||
{/* STATUS */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">STATUS</label>
|
||||
<select value={form.status}
|
||||
onChange={e => setForm(f => ({ ...f, status: e.target.value as any }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
||||
<option value="Pendente">PENDENTE</option>
|
||||
<option value="Pago">PAGO</option>
|
||||
<option value="Atrasado">ATRASADO</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* FORMA DE PAGAMENTO */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">FORMA DE PAGAMENTO</label>
|
||||
<select value={form.formaPagamento}
|
||||
onChange={e => setForm(f => ({ ...f, formaPagamento: e.target.value as any }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
||||
<option value="Pix">PIX</option>
|
||||
<option value="Boleto">BOLETO</option>
|
||||
<option value="Cartão">CARTÃO</option>
|
||||
<option value="Dinheiro">DINHEIRO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg text-sm uppercase transition-colors">CANCELAR</button>
|
||||
<button type="submit" className="px-8 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm text-sm uppercase flex items-center gap-2 transition-colors">
|
||||
<Plus size={16} /> SALVAR
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------- MAIN VIEW -----------
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const FinanceiroView: React.FC = () => {
|
||||
const [transacoes, setTransacoes] = useState<FinanceiroItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtroStatus, setFiltroStatus] = useState<'TODOS' | 'Pago' | 'Pendente' | 'Atrasado'>('TODOS');
|
||||
const [showFiltro, setShowFiltro] = useState(false);
|
||||
const [isNovoOpen, setIsNovoOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
HybridBackend.getFinanceiro().then((data) => {
|
||||
setTransacoes(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const transacoesFiltradas = filtroStatus === 'TODOS' ? transacoes : transacoes.filter(t => t.status === filtroStatus);
|
||||
|
||||
const totalRecebido = transacoes.filter(t => t.status === 'Pago').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
const totalPendente = transacoes.filter(t => t.status === 'Pendente').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
const totalAtrasado = transacoes.filter(t => t.status === 'Atrasado').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
|
||||
const handleConfirmarPagamento = async (item: FinanceiroItem) => {
|
||||
if (!window.confirm(`CONFIRMAR PAGAMENTO DE R$ ${Number(item.valor).toFixed(2)} DE ${item.pacienteNome}?`)) return;
|
||||
try {
|
||||
await HybridBackend.updateFinanceiro({ ...item, status: 'Pago' });
|
||||
toast.success(`PAGAMENTO DE ${item.pacienteNome} CONFIRMADO!`);
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('ERRO AO CONFIRMAR PAGAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluir = async (item: FinanceiroItem) => {
|
||||
if (!window.confirm(`TEM CERTEZA QUE DESEJA EXCLUIR O LANÇAMENTO DE ${item.pacienteNome}? ESTA AÇÃO NÃO PODE SER DESFEITA.`)) return;
|
||||
try {
|
||||
await HybridBackend.deleteFinanceiro(item.id);
|
||||
toast.success('LANÇAMENTO EXCLUÍDO!');
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('ERRO AO EXCLUIR LANÇAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (item: FinanceiroItem) => {
|
||||
toast.success(`RECIBO DE ${item.pacienteNome} GERADO COM SUCESSO!`);
|
||||
};
|
||||
|
||||
const filtroLabel = filtroStatus === 'TODOS' ? 'FILTRAR' : filtroStatus.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="FINANCEIRO & FATURAMENTO">
|
||||
<div className="flex gap-2 relative">
|
||||
{/* FILTRAR */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowFiltro(!showFiltro)}
|
||||
className={`border px-3 py-2 rounded-lg flex items-center gap-2 font-bold text-xs uppercase transition-colors ${filtroStatus !== 'TODOS' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}>
|
||||
<Filter size={18} /> {filtroLabel}
|
||||
</button>
|
||||
{showFiltro && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl z-20 min-w-[160px] overflow-hidden">
|
||||
{(['TODOS', 'Pago', 'Pendente', 'Atrasado'] as const).map(status => (
|
||||
<button key={status} onClick={() => { setFiltroStatus(status); setShowFiltro(false); }}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm font-bold uppercase hover:bg-gray-50 transition-colors flex items-center justify-between ${filtroStatus === status ? 'text-blue-700 bg-blue-50' : 'text-gray-700'
|
||||
}`}>
|
||||
{status.toUpperCase()}
|
||||
{filtroStatus === status && <div className="w-2 h-2 rounded-full bg-blue-600"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* NOVO LANÇAMENTO */}
|
||||
<button onClick={() => setIsNovoOpen(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-green-700 font-bold text-xs uppercase shadow-sm transition-colors">
|
||||
<Plus size={18} /> NOVO LANÇAMENTO
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* DASHBOARD CARDS */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">RECEBIDO (MÊS)</p>
|
||||
<h3 className="text-2xl font-bold text-green-600">R$ {totalRecebido.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-full text-green-600"><TrendingUp size={24} /></div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">A RECEBER / PENDENTE</p>
|
||||
<h3 className="text-2xl font-bold text-yellow-600">R$ {totalPendente.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-yellow-100 p-3 rounded-full text-yellow-600"><Clock size={24} /></div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">EM ATRASO</p>
|
||||
<h3 className="text-2xl font-bold text-red-600">R$ {totalAtrasado.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-red-100 p-3 rounded-full text-red-600"><TrendingDown size={24} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LISTA DE TRANSAÇÕES */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||
<h3 className="font-bold text-gray-800 uppercase">
|
||||
LANÇAMENTOS {filtroStatus !== 'TODOS' && <span className="text-blue-600 text-sm">({filtroStatus.toUpperCase()})</span>}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 font-bold uppercase">{transacoesFiltradas.length} REGISTRO(S)</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3">PACIENTE</th>
|
||||
<th className="px-6 py-3">DESCRIÇÃO</th>
|
||||
<th className="px-6 py-3">VENCIMENTO</th>
|
||||
<th className="px-6 py-3">VALOR</th>
|
||||
<th className="px-6 py-3">STATUS</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="p-6 text-center text-gray-500 uppercase font-bold">
|
||||
<div className="flex items-center justify-center gap-2"><Loader2 className="animate-spin" size={18} /> CARREGANDO...</div>
|
||||
</td></tr>
|
||||
) : transacoesFiltradas.length === 0 ? (
|
||||
<tr><td colSpan={6} className="p-10 text-center text-gray-400 uppercase font-bold text-sm">
|
||||
NENHUM LANÇAMENTO ENCONTRADO {filtroStatus !== 'TODOS' && `COM STATUS "${filtroStatus.toUpperCase()}"`}.
|
||||
</td></tr>
|
||||
) : transacoesFiltradas.map(t => (
|
||||
<tr key={t.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 font-bold text-gray-900 uppercase">{t.pacienteNome}</td>
|
||||
<td className="px-6 py-4 text-gray-600 font-medium uppercase">
|
||||
{t.descricao}
|
||||
<span className="text-[10px] text-gray-400 block font-bold">{t.formaPagamento?.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 font-medium">{new Date(t.dataVencimento).toLocaleDateString('pt-BR')}</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">R$ {Number(t.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||
${t.status === 'Pago' ? 'bg-green-100 text-green-700' :
|
||||
t.status === 'Atrasado' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'}`}>
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{t.status !== 'Pago' && (
|
||||
<button onClick={() => handleConfirmarPagamento(t)} title="CONFIRMAR PAGAMENTO"
|
||||
className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg transition-colors">
|
||||
<CheckCircle size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleDownload(t)} title="GERAR RECIBO"
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleExcluir(t)} title="EXCLUIR"
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLOSE FILTRO DROPDOWN when clicking outside */}
|
||||
{showFiltro && <div className="fixed inset-0 z-10" onClick={() => setShowFiltro(false)} />}
|
||||
|
||||
{/* NOVO LANÇAMENTO MODAL */}
|
||||
{isNovoOpen && <NovoLancamentoModal onClose={() => setIsNovoOpen(false)} onSaved={fetchData} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Trash2,
|
||||
Check,
|
||||
Building2,
|
||||
AlertCircle,
|
||||
PlusCircle,
|
||||
Camera,
|
||||
Stethoscope,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// --- STORES ---
|
||||
interface GTOItem {
|
||||
id: string;
|
||||
procedimentoId: string;
|
||||
codigoTUSS: string;
|
||||
codigoInterno: string;
|
||||
descricao: string;
|
||||
dente?: string;
|
||||
face?: string;
|
||||
arco?: string;
|
||||
quantidade: number;
|
||||
valorUnitario: number;
|
||||
valorTotal: number;
|
||||
observacao?: string;
|
||||
}
|
||||
|
||||
interface GTOStore {
|
||||
paciente: Paciente | null;
|
||||
dentista: Dentista | null;
|
||||
credenciada: string;
|
||||
items: GTOItem[];
|
||||
setPaciente: (p: Paciente | null) => void;
|
||||
setDentista: (d: Dentista | null) => void;
|
||||
setCredenciada: (c: string) => void;
|
||||
addItem: (item: GTOItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const useGTOStore = create<GTOStore>((set) => ({
|
||||
paciente: null,
|
||||
dentista: null,
|
||||
credenciada: 'CASSEMS - SEDE CENTRAL',
|
||||
items: [],
|
||||
setPaciente: (paciente) => set({ paciente }),
|
||||
setDentista: (dentista) => set({ dentista }),
|
||||
setCredenciada: (credenciada) => set({ credenciada }),
|
||||
addItem: (item) => set((state) => ({
|
||||
items: [...state.items, item].slice(0, 20)
|
||||
})),
|
||||
removeItem: (id) => set((state) => ({
|
||||
items: state.items.filter(i => i.id !== id)
|
||||
})),
|
||||
clear: () => set({ paciente: null, dentista: null, items: [] }),
|
||||
}));
|
||||
|
||||
// --- ODONTOGRAM DATA (4x2 Grid Layout) ---
|
||||
const DENTES_ADULTO = {
|
||||
q1: [14, 13, 12, 11, 18, 17, 16, 15], // Top: 14-11, Bottom: 18-15
|
||||
q2: [21, 22, 23, 24, 25, 26, 27, 28], // Top: 21-24, Bottom: 25-28
|
||||
q4: [44, 43, 42, 41, 48, 47, 46, 45], // Top: 44-41, Bottom: 48-45
|
||||
q3: [31, 32, 33, 34, 35, 36, 37, 38], // Top: 31-34, Bottom: 35-38
|
||||
extras: { q1: 19, q2: 29, q4: 49, q3: 39 }
|
||||
};
|
||||
|
||||
const DENTES_CRIANCA = {
|
||||
// Balanced 4x2 distribution with nulls for spacing (matching image layout)
|
||||
q1: [null, 53, 52, 51, null, null, 55, 54],
|
||||
q2: [61, 62, 63, null, 64, 65, null, null],
|
||||
q4: [null, 43, 42, 41, null, null, 46, 45],
|
||||
q3: [71, 72, 73, null, 74, 75, null, null]
|
||||
};
|
||||
|
||||
// --- COMPONENT ---
|
||||
export const LancarGTO: React.FC = () => {
|
||||
const store = useGTOStore();
|
||||
const toast = useToast();
|
||||
|
||||
const [pacientes, setPacientes] = useState<Paciente[]>([]);
|
||||
const [dentistas, setDentistas] = useState<Dentista[]>([]);
|
||||
const [especialidades, setEspecialidades] = useState<Especialidade[]>([]);
|
||||
const [procedimentos, setProcedimentos] = useState<Procedimento[]>([]);
|
||||
|
||||
const [selectedEsp, setSelectedEsp] = useState<string>('');
|
||||
const [selectedProc, setSelectedProc] = useState<Procedimento | null>(null);
|
||||
const [pacienteTipo, setPacienteTipo] = useState<'ADULTO' | 'CRIANCA'>('ADULTO');
|
||||
const [selectedDentes, setSelectedDentes] = useState<string[]>([]);
|
||||
const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null);
|
||||
const [face, setFace] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const p = await HybridBackend.getPacientes();
|
||||
const d = await HybridBackend.getDentistas();
|
||||
const es = await HybridBackend.getEspecialidades();
|
||||
const pr = await HybridBackend.getProcedimentos();
|
||||
setPacientes(p);
|
||||
setDentistas(d);
|
||||
setEspecialidades(es);
|
||||
setProcedimentos(pr);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filteredProcedimentos = useMemo(() => {
|
||||
if (!selectedEsp) return [];
|
||||
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
||||
}, [selectedEsp, procedimentos]);
|
||||
|
||||
const toggleDente = (num: string) => {
|
||||
if (selectedProc?.tipo_regiao !== 'DENTE') {
|
||||
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE DENTE INDIVIDUAL.");
|
||||
return;
|
||||
}
|
||||
setSelectedArco(null);
|
||||
setSelectedDentes(prev =>
|
||||
prev.includes(num) ? prev.filter(d => d !== num) : [...prev, num]
|
||||
);
|
||||
};
|
||||
|
||||
const selectArco = (arco: 'AI' | 'AS') => {
|
||||
if (selectedProc?.tipo_regiao !== 'ARCO') {
|
||||
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE ARCO.");
|
||||
return;
|
||||
}
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(arco);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!selectedProc) {
|
||||
toast.error("SELECIONE UM PROCEDIMENTO PRIMEIRO.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de Região
|
||||
if (selectedProc.tipo_regiao === 'DENTE' && selectedDentes.length === 0) {
|
||||
toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE AO MENOS UM DENTE.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProc.tipo_regiao === 'ARCO' && !selectedArco) {
|
||||
toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE UM ARCO (AI/AS).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de Face
|
||||
if (selectedProc.exige_face && (!face || face.trim() === '')) {
|
||||
toast.error("A FACE É OBRIGATÓRIA PARA ESTE PROCEDIMENTO.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: GTOItem = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
procedimentoId: selectedProc.id,
|
||||
codigoTUSS: selectedProc.codigo || '000000',
|
||||
codigoInterno: selectedProc.codigoInterno || '',
|
||||
descricao: selectedProc.nome,
|
||||
dente: selectedDentes.join(', '),
|
||||
arco: selectedArco || undefined,
|
||||
face: face,
|
||||
quantidade: 1,
|
||||
valorUnitario: selectedProc.valorParticular || 0,
|
||||
valorTotal: selectedProc.valorParticular || 0,
|
||||
observacao: obs,
|
||||
};
|
||||
|
||||
store.addItem(newItem);
|
||||
toast.success("ADICIONADO AO RASCUNHO.");
|
||||
|
||||
// Reset selection fields
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setFace('');
|
||||
setObs('');
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!store.paciente || !store.dentista || store.items.length === 0) {
|
||||
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.success("GTO GERADA COM SUCESSO!");
|
||||
store.clear();
|
||||
setSelectedEsp('');
|
||||
setSelectedProc(null);
|
||||
} catch {
|
||||
toast.error("ERRO AO GERAR GTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const totalGeral = store.items.reduce((sum, i) => sum + i.valorTotal, 0);
|
||||
|
||||
const renderDenteBtn = (num: number | string | null, keyIdx?: number) => {
|
||||
if (num === null) return <div key={`empty-${keyIdx}`} className="w-9 h-9" />;
|
||||
const sStr = num.toString();
|
||||
const isSelected = selectedDentes.includes(sStr);
|
||||
const isDisabled = !!selectedProc && selectedProc.tipo_regiao !== 'DENTE';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sStr}
|
||||
onClick={() => toggleDente(sStr)}
|
||||
disabled={isDisabled}
|
||||
className={`w-9 h-9 rounded-lg border-2 text-[11px] font-black transition-all shadow-sm flex items-center justify-center
|
||||
${isSelected
|
||||
? 'bg-blue-600 border-blue-700 text-white scale-110 z-10'
|
||||
: isDisabled
|
||||
? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50'
|
||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-400 hover:bg-blue-50'}
|
||||
`}
|
||||
>
|
||||
{sStr}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="LANÇAR GTO"
|
||||
description="MONTGEM DINÂMICA DE GUIA ODONTOLÓGICA (GTO)."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6 items-start">
|
||||
|
||||
{/* LEFT PANEL: ODONTOGRAM & ITEMS */}
|
||||
<div className="col-span-12 lg:col-span-8 space-y-6">
|
||||
|
||||
{/* ODONTOGRAM BOX */}
|
||||
<div className="bg-white rounded-3xl border border-gray-100 shadow-xl p-8 space-y-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="inline-flex bg-gray-100 p-1 rounded-xl shadow-inner">
|
||||
<button
|
||||
onClick={() => { setPacienteTipo('ADULTO'); setSelectedDentes([]); }}
|
||||
className={`px-8 py-2 rounded-lg text-xs font-black transition-all uppercase ${pacienteTipo === 'ADULTO' ? 'bg-blue-600 text-white shadow-md scale-105' : 'text-gray-500 hover:text-gray-800'}`}
|
||||
>
|
||||
Adulto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPacienteTipo('CRIANCA'); setSelectedDentes([]); }}
|
||||
className={`px-8 py-2 rounded-lg text-xs font-black transition-all uppercase ${pacienteTipo === 'CRIANCA' ? 'bg-blue-600 text-white shadow-md scale-105' : 'text-gray-500 hover:text-gray-800'}`}
|
||||
>
|
||||
Criança
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Odontogram Grid */}
|
||||
<div className="relative border-t border-b border-gray-50 py-10">
|
||||
{/* Visual Separators */}
|
||||
<div className="absolute left-1/2 top-4 bottom-4 w-[2px] bg-gray-200 -translate-x-1/2"></div>
|
||||
<div className="absolute top-1/2 left-4 right-4 h-[2px] bg-gray-200 -translate-y-1/2"></div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-16">
|
||||
{/* Q1 & Q2 (SUPERIOR) */}
|
||||
<div className="flex justify-end items-center gap-2 pr-2">
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q1)}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q1 : DENTES_CRIANCA.q1).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start items-center gap-2 pl-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q2 : DENTES_CRIANCA.q2).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q2)}
|
||||
</div>
|
||||
|
||||
{/* Q4 & Q3 (INFERIOR) */}
|
||||
<div className="flex justify-end items-center gap-2 pr-2">
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q4)}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q4 : DENTES_CRIANCA.q4).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start items-center gap-2 pl-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q3 : DENTES_CRIANCA.q3).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => selectArco('AI')}
|
||||
disabled={!!selectedProc && selectedProc.tipo_regiao !== 'ARCO'}
|
||||
className={`px-10 py-3 rounded-2xl border-2 font-black text-sm uppercase transition-all shadow-sm
|
||||
${selectedArco === 'AI' ? 'bg-amber-500 border-amber-600 text-white' :
|
||||
(!!selectedProc && selectedProc.tipo_regiao !== 'ARCO') ? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50' :
|
||||
'bg-white border-gray-100 text-gray-500 hover:border-amber-400'}`}
|
||||
>
|
||||
AI - Inferior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectArco('AS')}
|
||||
disabled={!!selectedProc && selectedProc.tipo_regiao !== 'ARCO'}
|
||||
className={`px-10 py-3 rounded-2xl border-2 font-black text-sm uppercase transition-all shadow-sm
|
||||
${selectedArco === 'AS' ? 'bg-amber-500 border-amber-600 text-white' :
|
||||
(!!selectedProc && selectedProc.tipo_regiao !== 'ARCO') ? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50' :
|
||||
'bg-white border-gray-100 text-gray-500 hover:border-amber-400'}`}
|
||||
>
|
||||
AS - Superior
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RASCUNHO LIST */}
|
||||
<div className="bg-white rounded-3xl border border-gray-100 overflow-hidden shadow-xl">
|
||||
<div className="bg-[#d49a4a] p-4 text-white font-black text-sm uppercase flex justify-between items-center tracking-widest">
|
||||
<span>PROCEDIMENTOS DO RASCUNHO</span>
|
||||
<div className="bg-white/20 px-3 py-1 rounded-full text-[10px]">{store.items.length}/20 ITENS</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{store.items.length === 0 && (
|
||||
<div className="py-16 text-center text-gray-300 font-bold uppercase flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center outline outline-gray-100 outline-offset-4">
|
||||
<AlertCircle size={32} />
|
||||
</div>
|
||||
NÃO HÁ ITENS ADICIONADOS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{store.items.map((item) => (
|
||||
<div key={item.id} className="border border-gray-100 rounded-2xl p-5 bg-gray-50/30 hover:bg-white transition-all shadow-sm group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="bg-blue-600 text-white text-[10px] font-black px-2 py-1 rounded-md uppercase shadow-sm">COD {item.codigoTUSS}</span>
|
||||
<h4 className="font-black text-gray-900 text-sm uppercase tracking-tight">{item.descricao}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase">
|
||||
<Users size={12} /> {store.paciente?.nome}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => store.removeItem(item.id)} className="text-gray-300 hover:text-red-600 p-2 transition-colors">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 text-[11px] font-black text-gray-500 uppercase tracking-wider mb-4 border-t border-gray-100 pt-4">
|
||||
<div>DENTE: <span className="text-blue-600 ml-1">{item.dente || '--'}</span></div>
|
||||
<div>ARCO: <span className="text-amber-600 ml-1">{item.arco || '--'}</span></div>
|
||||
<div>FACE: <span className="text-gray-900 ml-1">{item.face || '--'}</span></div>
|
||||
<div>VALOR: <span className="text-green-600 ml-1 text-xs">R$ {item.valorTotal.toFixed(2)}</span></div>
|
||||
</div>
|
||||
|
||||
{item.observacao && (
|
||||
<div className="text-[11px] text-gray-500 bg-white p-3 border border-gray-100 rounded-xl leading-relaxed">
|
||||
<span className="font-black text-gray-400 uppercase mr-2 opacity-50">OBS:</span> {item.observacao}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-6 border-t-2 border-dotted border-gray-200 flex justify-between items-center">
|
||||
<button className="flex items-center gap-2 text-blue-600 font-black text-[11px] uppercase hover:bg-blue-50 px-4 py-2 rounded-xl transition-all">
|
||||
<Camera size={16} /> ANEXAR DOCUMENTOS
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] mb-1">TOTAL ACUMULADO</p>
|
||||
<h3 className="text-3xl font-black text-gray-900 p-2 bg-gray-50 rounded-2xl border border-gray-100 shadow-inner">R$ {totalGeral.toFixed(2)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: WORKFLOW SELECTORS */}
|
||||
<div className="col-span-12 lg:col-span-4 space-y-4 sticky top-6">
|
||||
|
||||
{/* STEP 1: PACIENTE */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="flex items-center gap-3 text-blue-600 font-black text-base uppercase tracking-wider">
|
||||
<Users size={20} strokeWidth={3} /> 01. Paciente
|
||||
</div>
|
||||
<select
|
||||
value={store.paciente?.id || ''}
|
||||
onChange={(e) => {
|
||||
const p = pacientes.find(p => p.id === e.target.value);
|
||||
store.setPaciente(p || null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-blue-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O BENEFICIÁRIO</option>
|
||||
{pacientes.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-5">
|
||||
<div className="flex items-center gap-3 text-orange-500 font-black text-base uppercase tracking-wider">
|
||||
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedEsp}
|
||||
onChange={(e) => {
|
||||
setSelectedEsp(e.target.value);
|
||||
setSelectedProc(null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-orange-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A ÁREA...</option>
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedEsp && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
}}
|
||||
className="w-full p-4 bg-white border-2 border-orange-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedProc && (
|
||||
<div className="bg-orange-50 p-3 rounded-xl border border-orange-100 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<p className="text-[9px] font-black text-orange-600 uppercase mb-1">REGRAS DE SELEÇÃO</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-bold text-orange-800 flex items-center gap-1">
|
||||
<ChevronRight size={10} />
|
||||
{selectedProc.tipo_regiao === 'DENTE' ? 'EXIGE DENTE(S)' : selectedProc.tipo_regiao === 'ARCO' ? 'EXIGE ARCO (AI/AS)' : 'PROCEDIMENTO GERAL'}
|
||||
</span>
|
||||
{selectedProc.exige_face && (
|
||||
<span className="text-[10px] font-bold text-red-600 flex items-center gap-1">
|
||||
<ChevronRight size={10} /> EXIGE FACE CLINICA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 3: DENTISTA */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
|
||||
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
|
||||
</div>
|
||||
<select
|
||||
value={store.dentista?.id || ''}
|
||||
onChange={(e) => {
|
||||
const d = dentistas.find(d => d.id === e.target.value);
|
||||
store.setDentista(d || null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O PROFISSIONAL</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* STEP 4: DETALHES (FACE/OBS) */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex justify-between">
|
||||
<span>Face (O, M, D, V, L, P)</span>
|
||||
{selectedProc?.exige_face && <span className="text-red-500 font-black animate-pulse">* OBRIGATÓRIO</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={face}
|
||||
onChange={e => setFace(e.target.value.toUpperCase())}
|
||||
disabled={selectedProc?.tipo_regiao === 'GERAL' && !selectedProc?.exige_face}
|
||||
className={`w-full p-4 border-2 rounded-2xl text-sm font-black outline-none transition-all
|
||||
${selectedProc?.exige_face ? 'bg-red-50 border-red-100 focus:border-red-400' : 'bg-gray-50 border-gray-100 focus:border-amber-400'}
|
||||
`}
|
||||
placeholder={selectedProc?.exige_face ? "DIGITE AS FACES..." : "OPCIONAL"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 italic">Observações Clínicas</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={obs}
|
||||
onChange={e => setObs(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] h-20 focus:border-amber-400 outline-none transition-all"
|
||||
/>
|
||||
<PlusCircle size={24} className="absolute bottom-4 right-4 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div className="grid grid-cols-4 gap-4 p-2">
|
||||
<button
|
||||
onClick={handleAddItem}
|
||||
className="col-span-3 bg-[#2d6a4f] text-white py-5 rounded-3xl font-black text-lg uppercase shadow-2xl shadow-green-200 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
ADICIONAR ITEM <ChevronRight className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="col-span-1 bg-gray-900 text-white py-5 rounded-3xl flex items-center justify-center shadow-xl hover:bg-black active:scale-95 transition-all"
|
||||
title="FINALIZAR GTO"
|
||||
>
|
||||
<Check size={32} strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
ShieldCheck,
|
||||
BarChart3,
|
||||
Users,
|
||||
Smartphone,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
X,
|
||||
Stethoscope,
|
||||
Zap,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const FeatureCard: React.FC<{ icon: React.ReactNode; title: string; description: string }> = ({ icon, title, description }) => (
|
||||
<div className="bg-white/70 backdrop-blur-md p-8 rounded-3xl border border-white/50 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_50px_rgba(8,112,184,0.1)] transition-all duration-500 group">
|
||||
<div className="w-14 h-14 bg-blue-600/10 rounded-2xl flex items-center justify-center mb-6 group-hover:bg-blue-600 group-hover:text-white transition-all duration-500 text-blue-600">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 uppercase tracking-tight">{title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LandingPage: React.FC<{ onGetStarted: () => void }> = ({ onGetStarted }) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const heroImage = "https://images.unsplash.com/photo-1629909613654-28e377c37b09?q=80&w=2070&auto=format&fit=crop"; // Placeholder logic, will use local if needed later
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] font-sans selection:bg-blue-100 selection:text-blue-900 overflow-x-hidden">
|
||||
{/* Navigation */}
|
||||
<nav className={`fixed top-0 left-0 right-0 z-[100] transition-all duration-500 ${scrolled ? 'bg-white/80 backdrop-blur-xl border-b border-gray-100 py-3 shadow-sm' : 'bg-transparent py-6'}`}>
|
||||
<div className="max-w-7xl mx-auto px-6 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-200">
|
||||
<Stethoscope className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-2xl font-black text-gray-900 tracking-tighter uppercase italic">Score<span className="text-blue-600">Odonto</span></span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<a href="#features" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Recursos</a>
|
||||
<a href="#solutions" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Soluções</a>
|
||||
<a href="#mobile" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Mobile</a>
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-gray-900 text-white px-6 py-3 rounded-full text-xs font-black uppercase tracking-widest hover:bg-blue-600 hover:shadow-xl hover:shadow-blue-200 transition-all active:scale-95"
|
||||
>
|
||||
Começar Agora
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button className="md:hidden p-2 text-gray-900" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
{mobileMenuOpen ? <X size={28} /> : <Menu size={28} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-40 overflow-hidden">
|
||||
{/* Abstract Backgrounds */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-blue-50/50 to-transparent -z-10 translate-x-20 skew-x-12"></div>
|
||||
<div className="absolute top-40 left-10 w-72 h-72 bg-blue-400/10 rounded-full blur-3xl -z-10"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-8">
|
||||
|
||||
|
||||
<h1 className="text-6xl lg:text-7xl font-black text-gray-900 leading-[1.05] tracking-tighter">
|
||||
O Futuro da Sua Clínica é <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600 underline decoration-blue-200 underline-offset-8">Inteligente</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 leading-relaxed max-w-lg font-medium">
|
||||
Maximize a produtividade com gestão financeira, agenda com arraste e solte, e controle absoluto de tratamentos em uma plataforma única e elegante.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-blue-600 text-white px-10 py-5 rounded-2xl text-sm font-black uppercase tracking-widest shadow-2xl shadow-blue-200 hover:bg-blue-700 hover:-translate-y-1 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Acessar Plataforma <ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div className="flex items-center gap-4 py-2 px-4">
|
||||
<div className="flex -space-x-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="w-10 h-10 rounded-full border-2 border-white bg-gray-200 flex items-center justify-center text-[10px] font-bold overflow-hidden shadow-sm">
|
||||
<img src={`https://i.pravatar.cc/100?img=${i + 10}`} alt="User" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-bold uppercase tracking-tight">
|
||||
<span className="text-gray-900">+400 Clínicas</span> <br /> que já otimizaram processos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative z-10 rounded-[3rem] overflow-hidden shadow-[0_50px_100px_-20px_rgba(0,0,0,0.2)] border-8 border-white group">
|
||||
<img
|
||||
src="/dental_landing_hero.png"
|
||||
alt="Interface ScoreOdonto"
|
||||
className="w-full h-auto transform group-hover:scale-105 transition-all duration-700"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "https://images.unsplash.com/photo-1594832284143-588b813ed334?q=80&w=2070&auto=format&fit=crop";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent flex items-bottom p-10 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<p className="mt-auto text-white font-bold uppercase text-xs tracking-widest">Interface Real: Dashboard Analítico</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Element 1: Google Agenda */}
|
||||
<div className="absolute -top-10 -right-10 z-20 bg-white p-6 rounded-3xl shadow-2xl border border-blue-50 animate-bounce transition-all duration-1000 hidden md:block" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 text-blue-600 rounded-2xl">
|
||||
<Calendar size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Sincronização Total</div>
|
||||
<div className="text-lg font-black text-gray-900 tracking-tight">Google Agenda</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Element 2: WhatsApp CRM */}
|
||||
<div className="absolute -bottom-10 -left-10 z-20 bg-white p-6 rounded-3xl shadow-2xl border border-green-50 animate-bounce transition-all duration-1000">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 text-green-600 rounded-2xl">
|
||||
<MessageCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Comunicação</div>
|
||||
<div className="text-lg font-black text-gray-900 tracking-tight">CRM WhatsApp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof Bar */}
|
||||
<div className="bg-white border-y border-gray-100 py-10">
|
||||
<div className="max-w-7xl mx-auto px-6 overflow-hidden">
|
||||
<p className="text-center text-[10px] font-black text-gray-400 uppercase tracking-[0.3em] mb-10">Conectado com os principais convênios</p>
|
||||
<div className="flex justify-between items-center opacity-30 grayscale gap-10">
|
||||
<span className="text-2xl font-black italic">CASSEMS</span>
|
||||
<span className="text-2xl font-black italic">UNIMED</span>
|
||||
<span className="text-2xl font-black italic">BRADESCO</span>
|
||||
<span className="text-2xl font-black italic">SULAMÉRICA</span>
|
||||
<span className="text-2xl font-black italic">AMIL DENTAL</span>
|
||||
<span className="text-2xl font-black italic">REDE DENTAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-3xl mx-auto mb-20 space-y-4">
|
||||
<h2 className="text-4xl lg:text-5xl font-black text-gray-900 leading-tight tracking-tighter uppercase">Todo o Controle na Sua Mão</h2>
|
||||
<p className="text-lg text-gray-600 font-medium">Focamos na tecnologia para que você foque no que mais importa: o sorriso do seu paciente.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<FeatureCard
|
||||
icon={<Calendar size={28} />}
|
||||
title="Agenda 4.0"
|
||||
description="Controle total com arraste e solte, cores por profissional e sincronização em tempo real."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<ShieldCheck size={28} />}
|
||||
title="GTO Segura"
|
||||
description="Lançamento simplificado de guias de convênio com validação automática de erros."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users size={28} />}
|
||||
title="Gestão de Pacientes"
|
||||
description="CRM odontológico completo com histórico clínico, anexos e ortodontia avançada."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BarChart3 size={28} />}
|
||||
title="BI Financeiro"
|
||||
description="Gráficos de produtividade, fluxo de caixa e gestão de taxas de operadoras."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-7xl mx-auto bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[3rem] p-12 lg:p-24 overflow-hidden relative shadow-2xl shadow-blue-300">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-black/10 rounded-full translate-y-1/2 -translate-x-1/2 blur-3xl"></div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-3xl mx-auto">
|
||||
<h2 className="text-4xl lg:text-6xl font-black text-white leading-tight tracking-tighter uppercase italic">
|
||||
Sua Clínica Elevada ao <span className="bg-white text-blue-600 px-4 py-1 rounded-2xl not-italic">Próximo Nível</span>
|
||||
</h2>
|
||||
<p className="text-blue-100 text-xl font-medium">
|
||||
Junte-se a centenas de profissionais que já transformaram a gestão de seus consultórios. Comece agora gratuitamente.
|
||||
</p>
|
||||
<div className="pt-6">
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-white text-blue-600 px-12 py-6 rounded-3xl text-sm font-black uppercase tracking-widest shadow-xl hover:bg-gray-50 hover:-translate-y-1 transition-all active:scale-95"
|
||||
>
|
||||
Testar Demonstrativo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-gray-100 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-6 grid md:grid-cols-4 gap-12">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Stethoscope className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="text-xl font-black text-gray-900 tracking-tighter uppercase italic">Score<span className="text-blue-600">Odonto</span></span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
A plataforma líder em gestão odontológica para clínicas que buscam excelência e eficiência.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Smartphone size={16} /></div>
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Users size={16} /></div>
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Zap size={16} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Produto</h4>
|
||||
<ul className="space-y-3 text-sm text-gray-500 font-bold uppercase tracking-tight">
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Funcionalidades</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Segurança</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">API</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Mobile</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Empresa</h4>
|
||||
<ul className="space-y-3 text-sm text-gray-500 font-bold uppercase tracking-tight">
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Sobre Nós</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Blog</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Carreiras</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Contato</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Suporte</h4>
|
||||
<div className="p-4 bg-gray-50 rounded-2xl border border-gray-100">
|
||||
<p className="text-xs text-gray-500 font-bold uppercase mb-2">Central de Ajuda</p>
|
||||
<a href="mailto:suporte@scoreodonto.com" className="text-sm font-black text-blue-600 hover:underline">suporte@scoreodonto.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mt-20 pt-10 border-t border-gray-50 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">© 2026 ScoreOdonto. Todos os direitos reservados.</p>
|
||||
<div className="flex gap-8 text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||||
<span className="hover:text-gray-900 cursor-pointer">Termos</span>
|
||||
<span className="hover:text-gray-900 cursor-pointer">Privacidade</span>
|
||||
<span className="hover:text-gray-900 cursor-pointer">Cookies</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Filter, MessageSquare, UserPlus, CheckCircle, X, Loader2, Plus, ChevronDown } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Lead, Dentista } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="col-span-3 flex justify-center items-center py-10">
|
||||
<Loader2 className="animate-spin text-blue-600" size={32} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorDisplay = ({ error }: { error: Error }) => (
|
||||
<div className="col-span-3 text-center py-10 text-red-500 uppercase font-bold text-sm">
|
||||
ERRO AO CARREGAR LEADS: {error.message}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NovoLeadModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState({ nome: '', sobrenome: '', whatsapp: '', tipoInteresse: 'Particular' });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.nome || !form.whatsapp) {
|
||||
toast.error('PREENCHA NOME E WHATSAPP.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.saveLead({
|
||||
...form,
|
||||
status: 'Novo',
|
||||
dataCadastro: new Date().toISOString(),
|
||||
} as any);
|
||||
toast.success('LEAD CADASTRADO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO CADASTRAR LEAD.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-green-50 to-white">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-green-500" /> CADASTRAR NOVO LEAD
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl mx-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.nome}
|
||||
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
||||
placeholder="NOME"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">SOBRENOME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sobrenome}
|
||||
onChange={e => setForm(f => ({ ...f, sobrenome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
||||
placeholder="SOBRENOME"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">WHATSAPP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.whatsapp}
|
||||
onChange={e => setForm(f => ({ ...f, whatsapp: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm"
|
||||
placeholder="(XX) XXXXX-XXXX"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TIPO DE INTERESSE</label>
|
||||
<select
|
||||
value={form.tipoInteresse}
|
||||
onChange={e => setForm(f => ({ ...f, tipoInteresse: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm bg-white uppercase-select"
|
||||
>
|
||||
<option value="Particular">PARTICULAR</option>
|
||||
<option value="Plano">PLANO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-6">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-sm uppercase flex items-center gap-2">
|
||||
<UserPlus size={16} /> CADASTRAR
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const LeadsView: React.FC = () => {
|
||||
const { data: leads, isLoading, error, refresh } = useHybridBackend<Lead[]>(HybridBackend.getLeads);
|
||||
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
|
||||
const [isConvertModalOpen, setIsConvertModalOpen] = useState(false);
|
||||
const [isNovoLeadOpen, setIsNovoLeadOpen] = useState(false);
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('TODOS');
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Effect to handle Esc key to close modal
|
||||
useEffect(() => {
|
||||
if (!isConvertModalOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsConvertModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isConvertModalOpen]);
|
||||
|
||||
const handleConvert = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedLead) return;
|
||||
|
||||
try {
|
||||
// Mocking the conversion as the backend method is not yet implemented
|
||||
toast.info(`FUNCIONALIDADE DE CONVERSÃO PARA ${selectedLead.nome} EM DESENVOLVIMENTO.`);
|
||||
setIsConvertModalOpen(false);
|
||||
// In a real scenario, this would create a patient and an appointment
|
||||
} catch {
|
||||
toast.error("FALHA AO CONVERTER LEAD.");
|
||||
}
|
||||
};
|
||||
|
||||
const filterOptions = ['TODOS', 'NOVO', 'CONVERTIDO'];
|
||||
|
||||
const filteredLeads = leads?.filter(lead => {
|
||||
if (filterStatus === 'TODOS') return true;
|
||||
return lead.status?.toUpperCase() === filterStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="GESTÃO DE LEADS">
|
||||
<div className="flex gap-2">
|
||||
{/* Filter Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(o => !o)}
|
||||
className="bg-white border border-gray-300 text-gray-600 px-3 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-gray-50 uppercase font-bold"
|
||||
>
|
||||
<Filter size={16} />
|
||||
{filterStatus}
|
||||
<ChevronDown size={14} className={`transition-transform ${isFilterOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isFilterOpen && (
|
||||
<div className="absolute right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[140px]">
|
||||
{filterOptions.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { setFilterStatus(opt); setIsFilterOpen(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm font-bold uppercase hover:bg-gray-50 ${filterStatus === opt ? 'text-blue-600 bg-blue-50' : 'text-gray-700'}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Lead Button */}
|
||||
<button
|
||||
onClick={() => setIsNovoLeadOpen(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-green-700 uppercase font-bold shadow-sm"
|
||||
>
|
||||
<Plus size={16} /> NOVO LEAD
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{error && <ErrorDisplay error={error} />}
|
||||
{filteredLeads && filteredLeads.map(lead => (
|
||||
<div key={lead.id} className={`bg-white rounded-xl shadow-sm border ${lead.status === 'Novo' ? 'border-amber-200 bg-amber-50/30' : 'border-gray-200'} p-6 relative`}>
|
||||
{lead.status === 'Novo' && (
|
||||
<div className="absolute top-4 right-4 w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 uppercase">{lead.nome} {lead.sobrenome}</h3>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1 mt-1 font-medium">
|
||||
<MessageSquare size={14} /> {lead.whatsapp}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${lead.tipoInteresse === 'Particular' ? 'bg-orange-100 text-orange-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
{lead.tipoInteresse}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-6 font-bold uppercase">
|
||||
CADASTRADO EM: {new Date(lead.dataCadastro).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{lead.status !== 'Convertido' ? (
|
||||
<button
|
||||
onClick={() => { setSelectedLead(lead); setIsConvertModalOpen(true); }}
|
||||
className="w-full py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center justify-center gap-2 font-bold transition-colors uppercase text-xs"
|
||||
>
|
||||
<UserPlus size={18} /> AGENDAR E CADASTRAR
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full py-2 bg-gray-100 text-gray-500 rounded-lg flex items-center justify-center gap-2 font-bold uppercase text-xs">
|
||||
<CheckCircle size={18} /> CONVERTIDO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredLeads?.length === 0 && !isLoading && (
|
||||
<div className="col-span-3 text-center py-10 text-gray-400 uppercase font-bold text-sm">
|
||||
NENHUM LEAD ENCONTRADO.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConvertModalOpen && selectedLead && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-blue-500" /> CONVERTER LEAD
|
||||
</h3>
|
||||
<button onClick={() => setIsConvertModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleConvert} className="space-y-4 max-w-2xl mx-auto">
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-4 text-xs text-blue-800 border border-blue-100 font-bold uppercase">
|
||||
AO SALVAR, O SISTEMA IRÁ AUTOMATICAMENTE <strong>CADASTRAR O PACIENTE</strong> E CRIAR O AGENDAMENTO.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">PACIENTE</label>
|
||||
<input disabled value={`${selectedLead.nome} ${selectedLead.sobrenome}`.toUpperCase()} className="w-full bg-gray-100 border border-gray-300 rounded-lg p-2.5 text-gray-500 uppercase font-bold" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DATA AGENDAMENTO</label>
|
||||
<input type="date" required className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">HORA</label>
|
||||
<input type="time" required className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DENTISTA RESPONSÁVEL</label>
|
||||
<select className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select font-semibold">
|
||||
{dentistas && dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-3 rounded-lg font-bold hover:bg-green-700 uppercase text-sm shadow-sm transition-all">
|
||||
CONFIRMAR AGENDAMENTO
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNovoLeadOpen && (
|
||||
<NovoLeadModal onClose={() => setIsNovoLeadOpen(false)} onSaved={refresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, Lock, User, ArrowRight, ShieldCheck } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await HybridBackend.login(email, password);
|
||||
if (success) {
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
setError('CREDENCIAIS INVÁLIDAS. VERIFIQUE EMAIL E SENHA.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('ERRO DE CONEXÃO. TENTE NOVAMENTE.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
|
||||
<div className="bg-blue-600 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-white mx-auto backdrop-blur-sm mb-4">
|
||||
<Database size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white uppercase tracking-wide">SCOREODONTO</h1>
|
||||
<p className="text-blue-100 text-xs font-bold uppercase mt-1">SISTEMA INTEGRADO MYSQL + GOOGLE</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-bold text-gray-800 uppercase mb-6 text-center flex items-center justify-center gap-2">
|
||||
<Lock size={18} className="text-blue-600"/> ACESSO RESTRITO
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">USUÁRIO / EMAIL</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-medium"
|
||||
placeholder="ex: usuario@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">SENHA DE ACESSO</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-medium"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-600 text-xs font-bold uppercase rounded-lg border border-red-100 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold uppercase flex items-center justify-center gap-2 transition-all shadow-lg hover:shadow-xl disabled:opacity-70 disabled:cursor-not-allowed mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
<span>VERIFICANDO...</span>
|
||||
) : (
|
||||
<>ENTRAR NO SISTEMA <ArrowRight size={18} /></>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-gray-400 text-[10px] font-bold uppercase">
|
||||
<ShieldCheck size={12}/> AMBIENTE SEGURO E CRIPTOGRAFADO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ArrowUpDown,
|
||||
Filter,
|
||||
Calendar as CalendarIcon
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// Debounce hook
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
interface Guia {
|
||||
id: string;
|
||||
numeroGuiaPrestador: string;
|
||||
numeroGuiaOperadora: string | null;
|
||||
dataSolicitacao: string;
|
||||
tipoTratamento: string;
|
||||
status: 'EM_ANALISE' | 'AUTORIZADO' | 'AUTORIZADO_PARCIAL' | 'NEGADO' | 'CANCELADO';
|
||||
beneficiarioId: string;
|
||||
beneficiarioNome: string;
|
||||
beneficiarioIdentificacao: string;
|
||||
}
|
||||
|
||||
interface GuiasResponse {
|
||||
data: Guia[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// --- Status Badge Component ---
|
||||
const StatusBadge: React.FC<{ status: Guia['status'] }> = ({ status }) => {
|
||||
const configs = {
|
||||
AUTORIZADO: { label: 'Autorizado', color: 'bg-green-100 text-green-700 border-green-200' },
|
||||
AUTORIZADO_PARCIAL: { label: 'Autorizado Parcialmente', color: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
|
||||
NEGADO: { label: 'Negado', color: 'bg-red-100 text-red-700 border-red-200' },
|
||||
EM_ANALISE: { label: 'Em Análise', color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||
CANCELADO: { label: 'Cancelado', color: 'bg-gray-100 text-gray-700 border-gray-200' },
|
||||
};
|
||||
|
||||
const config = configs[status] || configs.EM_ANALISE;
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-[10px] font-bold border uppercase ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main View ---
|
||||
export const MeusTratamentos: React.FC = () => {
|
||||
// Filters & State
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [gto, setGto] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [dataInicio, setDataInicio] = useState('');
|
||||
const [dataFim, setDataFim] = useState('');
|
||||
const [sortField, setSortField] = useState('dataSolicitacao');
|
||||
const [sortOrder, setSortOrder] = useState<'ASC' | 'DESC'>('DESC');
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const debouncedGto = useDebounce(gto, 500);
|
||||
|
||||
// API Fetcher
|
||||
const fetchGuias = async (): Promise<GuiasResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
search: debouncedSearch,
|
||||
gto: debouncedGto,
|
||||
status,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
sortField,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const res = await fetch(`http://localhost:3002/api/guias?${params}`);
|
||||
if (!res.ok) throw new Error('Falha ao carregar guias');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['guias', page, debouncedSearch, debouncedGto, status, dataInicio, dataFim, sortField, sortOrder],
|
||||
queryFn: fetchGuias,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('DESC');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!data?.data) return;
|
||||
const headers = ['Data Solicitação', 'Identificação', 'Beneficiário', 'Tipo Tratamento', 'GTO', 'Situação'];
|
||||
const rows = data.data.map(g => [
|
||||
format(parseISO(g.dataSolicitacao), 'dd/MM/yyyy'),
|
||||
g.beneficiarioIdentificacao,
|
||||
g.beneficiarioNome,
|
||||
g.tipoTratamento,
|
||||
g.numeroGuiaPrestador,
|
||||
g.status
|
||||
]);
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
headers.join(",") + "\n" +
|
||||
rows.map(e => e.join(",")).join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", `tratamentos_${format(new Date(), 'yyyyMMdd')}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-4">
|
||||
<PageHeader
|
||||
title="Meus Tratamentos"
|
||||
description="LISTAGEM E GESTÃO DE GUIAS DE TRATAMENTO ODONTOLÓGICO (GTO)."
|
||||
>
|
||||
<button
|
||||
onClick={exportToCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-bold uppercase shadow-sm"
|
||||
>
|
||||
<Download size={18} /> Exportar CSV
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Status da Guia</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
||||
>
|
||||
<option value="">TODOS OS STATUS</option>
|
||||
<option value="AUTORIZADO">AUTORIZADO</option>
|
||||
<option value="AUTORIZADO_PARCIAL">AUTORIZADO PARCIALMENTE</option>
|
||||
<option value="NEGADO">NEGADO</option>
|
||||
<option value="EM_ANALISE">EM ANÁLISE</option>
|
||||
<option value="CANCELADO">CANCELADO</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-48">
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">GTO (Número)</label>
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="EX: 177903"
|
||||
value={gto}
|
||||
onChange={(e) => { setGto(e.target.value); setPage(1); }}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[300px]">
|
||||
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Pesquisar Beneficiário</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="DIGITE NOME OU IDENTIFICAÇÃO..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value.toUpperCase()); setPage(1); }}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2 border-t border-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon size={16} className="text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={dataInicio}
|
||||
onChange={(e) => { setDataInicio(e.target.value); setPage(1); }}
|
||||
className="px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-xs font-semibold"
|
||||
/>
|
||||
<span className="text-gray-400 font-bold text-xs uppercase">até</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dataFim}
|
||||
onChange={(e) => { setDataFim(e.target.value); setPage(1); }}
|
||||
className="px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-xs font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch(''); setGto(''); setStatus(''); setDataInicio(''); setDataFim(''); setPage(1);
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-600 hover:text-blue-800 uppercase"
|
||||
>
|
||||
Limpar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex-1 flex flex-col min-h-[400px]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-6 py-4">
|
||||
<button onClick={() => handleSort('dataSolicitacao')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
||||
Data Solicitação <ArrowUpDown size={14} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-4">
|
||||
<span className="text-[11px] font-extrabold text-gray-500 uppercase tracking-wider">Identificação do Beneficiário</span>
|
||||
</th>
|
||||
<th className="px-6 py-4">
|
||||
<button onClick={() => handleSort('beneficiarioNome')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
||||
Nome Beneficiário <ArrowUpDown size={14} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-4">
|
||||
<span className="text-[11px] font-extrabold text-gray-500 uppercase tracking-wider">Tipo Tratamento</span>
|
||||
</th>
|
||||
<th className="px-6 py-4">
|
||||
<button onClick={() => handleSort('numeroGuiaPrestador')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
||||
GTO <ArrowUpDown size={14} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-4">
|
||||
<button onClick={() => handleSort('status')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
||||
Situação da GTO <ArrowUpDown size={14} />
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
<td colSpan={6} className="px-6 py-4 h-16 bg-gray-50/30"></td>
|
||||
</tr>
|
||||
))
|
||||
) : isError ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-red-500 font-bold uppercase">Erro ao carregar dados.</td>
|
||||
</tr>
|
||||
) : data?.data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-400 font-bold uppercase">Nenhum tratamento encontrado.</td>
|
||||
</tr>
|
||||
) : (
|
||||
data?.data.map((gui) => (
|
||||
<tr key={gui.id} className="hover:bg-blue-50/50 transition-colors cursor-pointer group">
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-700">
|
||||
{format(parseISO(gui.dataSolicitacao), 'dd/MM/yyyy')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-blue-600 group-hover:underline">
|
||||
{gui.beneficiarioIdentificacao}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-bold text-gray-900 uppercase">{gui.beneficiarioNome}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{gui.tipoTratamento}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-gray-800">
|
||||
{gui.numeroGuiaPrestador}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={gui.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="mt-auto bg-gray-50 px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase">
|
||||
Mostrando <span className="text-gray-900">{data?.data.length || 0}</span> de <span className="text-gray-900">{data?.total || 0}</span> resultados
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className="p-2 border border-gray-300 rounded-lg hover:bg-white disabled:opacity-30 disabled:hover:bg-transparent transition-colors shadow-sm"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-8 h-8 rounded-lg text-xs font-bold transition-all ${p === page ? 'bg-blue-600 text-white shadow-md' : 'text-gray-600 hover:bg-white border border-transparent hover:border-gray-200'}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className="p-2 border border-gray-300 rounded-lg hover:bg-white disabled:opacity-30 disabled:hover:bg-transparent transition-colors shadow-sm"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, Search, Filter, CheckCircle2, Trash2, Calendar, DollarSign, UserCheck, ShieldAlert, MessageSquare, Inbox, ArrowRight, MoreHorizontal } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Notificacao } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const NotificationsView: React.FC = () => {
|
||||
const [notificacoes, setNotificacoes] = useState<Notificacao[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'unread' | 'important'>('all');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const toast = useToast();
|
||||
|
||||
const fetchNotificacoes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await HybridBackend.getNotifications();
|
||||
// Data de mais nova para mais antiga
|
||||
setNotificacoes(data.sort((a, b) => new Date(b.data).getTime() - new Date(a.data).getTime()));
|
||||
} catch (err) {
|
||||
toast.error("Erro ao carregar notificações");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificacoes();
|
||||
}, []);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await HybridBackend.markNotificationRead(id);
|
||||
setNotificacoes(prev => prev.map(n => n.id === id ? { ...n, lida: true } : n));
|
||||
toast.success("Notificação marcada como lida");
|
||||
} catch (err) {
|
||||
toast.error("Erro ao atualizar notificação");
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
const unread = notificacoes.filter(n => !n.lida);
|
||||
if (unread.length === 0) return;
|
||||
|
||||
try {
|
||||
await HybridBackend.markAllNotificationsRead();
|
||||
setNotificacoes(prev => prev.map(n => ({ ...n, lida: true })));
|
||||
toast.success("Todas as notificações foram marcadas como lidas");
|
||||
} catch (err) {
|
||||
toast.error("Erro ao atualizar notificações");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: string) => {
|
||||
try {
|
||||
await HybridBackend.deleteNotification(id);
|
||||
setNotificacoes(prev => prev.filter(n => n.id !== id));
|
||||
toast.success("Notificação removida");
|
||||
} catch (err) {
|
||||
toast.error("Erro ao excluir notificação");
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'agenda': return <Calendar size={20} className="text-blue-500" />;
|
||||
case 'financeiro': return <DollarSign size={20} className="text-emerald-500" />;
|
||||
case 'lead': return <UserCheck size={20} className="text-amber-500" />;
|
||||
case 'sistema': return <ShieldAlert size={20} className="text-red-500" />;
|
||||
default: return <MessageSquare size={20} className="text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNotificacoes = notificacoes.filter(n => {
|
||||
const matchesSearch = n.titulo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
n.mensagem.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesTab = activeTab === 'all' || (activeTab === 'unread' && !n.lida) || (activeTab === 'important' && n.tipo === 'sistema');
|
||||
const matchesType = filterType === 'all' || n.tipo === filterType;
|
||||
return matchesSearch && matchesTab && matchesType;
|
||||
});
|
||||
|
||||
const unreadCount = notificacoes.filter(n => !n.lida).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<PageHeader title="CENTRO DE NOTIFICAÇÕES">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-600 hover:text-blue-600 hover:border-blue-200 rounded-xl font-bold text-xs uppercase tracking-wider transition-all shadow-sm"
|
||||
>
|
||||
<CheckCircle2 size={16} /> Marcar todas como lidas
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchNotificacoes}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 rounded-xl font-bold text-xs uppercase tracking-wider transition-all shadow-md active:scale-95"
|
||||
>
|
||||
<Bell size={16} /> Atualizar
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Stats Dashboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', count: notificacoes.length, icon: Inbox, color: 'text-slate-600', bg: 'bg-slate-100' },
|
||||
{ label: 'Não Lidas', count: unreadCount, icon: Bell, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: 'Sistema', count: notificacoes.filter(n => n.tipo === 'sistema').length, icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
|
||||
{ label: 'Financeiro', count: notificacoes.filter(n => n.tipo === 'financeiro').length, icon: DollarSign, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-4 group hover:shadow-md transition-all">
|
||||
<div className={`p-3 rounded-xl ${stat.bg} ${stat.color} group-hover:scale-110 transition-transform`}>
|
||||
<stat.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{stat.label}</p>
|
||||
<h4 className="text-2xl font-black text-slate-800 tracking-tighter">{stat.count}</h4>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 shadow-sm overflow-hidden min-h-[600px] flex flex-col">
|
||||
{/* Filters Bar */}
|
||||
<div className="p-8 border-b border-slate-50 flex flex-col lg:flex-row lg:items-center justify-between gap-6 bg-slate-50/30">
|
||||
<div className="flex p-1.5 bg-slate-100 rounded-2xl w-fit">
|
||||
{[
|
||||
{ id: 'all', label: 'Todas' },
|
||||
{ id: 'unread', label: 'Não Lidas' },
|
||||
{ id: 'important', label: 'Importantes' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${activeTab === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.id === 'unread' && unreadCount > 0 && (
|
||||
<span className="ml-2 bg-blue-600 text-white px-1.5 py-0.5 rounded-md text-[9px]">{unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 max-w-2xl gap-3">
|
||||
<div className="relative flex-1 group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-500 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar notificações..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border border-slate-200 rounded-2xl text-sm font-bold uppercase tracking-tight focus:ring-4 focus:ring-blue-50 focus:border-blue-500 outline-none transition-all placeholder:text-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-4 py-3.5 bg-white border border-slate-200 rounded-2xl text-xs font-black uppercase tracking-widest focus:ring-4 focus:ring-blue-50 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="all">Filtro: Todos</option>
|
||||
<option value="lead">Novos Leads</option>
|
||||
<option value="agenda">Agendamentos</option>
|
||||
<option value="financeiro">Financeiro</option>
|
||||
<option value="sistema">Alertas de Sistema</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-[400px]">
|
||||
<div className="w-12 h-12 border-4 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
|
||||
<p className="text-slate-400 font-black uppercase tracking-widest text-xs">Carregando Histórico...</p>
|
||||
</div>
|
||||
) : filteredNotificacoes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-20 text-center">
|
||||
<div className="w-32 h-32 bg-slate-50 rounded-[3rem] flex items-center justify-center mb-6">
|
||||
<Inbox size={48} className="text-slate-200" />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tighter">Nenhuma notificação encontrada</h3>
|
||||
<p className="text-slate-400 text-sm mt-2 max-w-sm">
|
||||
Não existem registros que correspondam aos seus filtros ou pesquisa no momento.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{filteredNotificacoes.map((notif, idx) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`group p-8 transition-all hover:bg-slate-50/50 flex flex-col md:flex-row md:items-center gap-6 relative ${!notif.lida ? 'bg-blue-50/20' : ''}`}
|
||||
style={{ animationDelay: `${idx * 50}ms` }}
|
||||
>
|
||||
{!notif.lida && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-blue-600 rounded-r-full shadow-[2px_0_10px_rgba(37,99,235,0.3)]" />
|
||||
)}
|
||||
|
||||
<div className={`p-4 rounded-[1.5rem] flex-shrink-0 shadow-sm border border-white transition-transform group-hover:scale-110 ${notif.tipo === 'agenda' ? 'bg-blue-100/50 text-blue-600' :
|
||||
notif.tipo === 'financeiro' ? 'bg-emerald-100/50 text-emerald-600' :
|
||||
notif.tipo === 'lead' ? 'bg-amber-100/50 text-amber-600' :
|
||||
'bg-red-100/50 text-red-600'
|
||||
}`}>
|
||||
{getIcon(notif.tipo)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-[10px] font-black uppercase tracking-[0.2em] px-3 py-1 rounded-full ${notif.tipo === 'agenda' ? 'bg-blue-100 text-blue-600' :
|
||||
notif.tipo === 'financeiro' ? 'bg-emerald-100 text-emerald-600' :
|
||||
notif.tipo === 'lead' ? 'bg-amber-100 text-amber-600' :
|
||||
'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{notif.tipo}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-slate-300 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Calendar size={12} />
|
||||
{new Date(notif.data).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-slate-300 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Clock size={12} className="ml-2" />
|
||||
{new Date(notif.data).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-black tracking-tighter uppercase ${!notif.lida ? 'text-slate-900' : 'text-slate-600'}`}>
|
||||
{notif.titulo}
|
||||
</h3>
|
||||
<p className="text-slate-500 text-sm leading-relaxed max-w-4xl">
|
||||
{notif.mensagem}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-all transform translate-x-4 group-hover:translate-x-0">
|
||||
{!notif.lida && (
|
||||
<button
|
||||
onClick={() => markAsRead(notif.id)}
|
||||
className="p-3 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-100 hover:bg-blue-700 hover:-translate-y-1 transition-all"
|
||||
title="Marcar como lida"
|
||||
>
|
||||
<CheckCircle2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notif.id)}
|
||||
className="p-3 bg-white text-slate-400 border border-slate-100 rounded-xl shadow-sm hover:text-red-600 hover:border-red-100 hover:-translate-y-1 transition-all"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
<button className="p-3 bg-white text-slate-400 border border-slate-100 rounded-xl shadow-sm hover:text-slate-600 hover:-translate-y-1 transition-all">
|
||||
<MoreHorizontal size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
||||
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
Mostrando {filteredNotificacoes.length} de {notificacoes.length} registros
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-5 py-2 rounded-xl bg-white border border-slate-200 text-[11px] font-black uppercase tracking-wider text-slate-500 hover:bg-slate-50 transition-colors">Voltar</button>
|
||||
<button className="px-5 py-2 rounded-xl bg-white border border-slate-200 text-[11px] font-black uppercase tracking-wider text-slate-500 hover:bg-slate-50 transition-colors">Próximo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Clock = ({ size, className }: { size: number, className?: string }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
|
||||
);
|
||||
@@ -0,0 +1,958 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Activity, ArrowRight, ArrowLeft, X, AlertTriangle, Info, ShieldAlert,
|
||||
Camera, Upload, CheckCircle, Clock, Stethoscope, FileText, Heart, Sparkles,
|
||||
ChevronDown, ChevronUp, User, Calendar, Percent, Scissors, RotateCcw,
|
||||
Wrench, ShieldCheck, Droplets, FileDown, ClipboardCheck, AlertCircle,
|
||||
Brush, Crosshair
|
||||
} from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { TratamentoOrto, EvolucaoOrto, TipoEventoOrto, CooperacaoOrto } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
/* ================================================================
|
||||
HELPER: calcula idade a partir de data de nascimento
|
||||
================================================================ */
|
||||
function calcAge(dob?: string): number | null {
|
||||
if (!dob) return null;
|
||||
const birth = new Date(dob);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
function tipoPacienteLabel(age: number | null): string {
|
||||
if (age === null) return '';
|
||||
if (age < 10) return 'INFANTIL';
|
||||
if (age <= 17) return 'ADOLESCENTE';
|
||||
return 'ADULTO';
|
||||
}
|
||||
|
||||
const COOPERATION_COLORS: Record<string, string> = {
|
||||
'Boa': 'bg-green-100 text-green-700',
|
||||
'Média': 'bg-amber-100 text-amber-700',
|
||||
'Ruim': 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const FLAG_STYLES: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
|
||||
'danger': { bg: 'bg-red-50 border-red-200', text: 'text-red-700', icon: <ShieldAlert size={14} /> },
|
||||
'warning': { bg: 'bg-amber-50 border-amber-200', text: 'text-amber-700', icon: <AlertTriangle size={14} /> },
|
||||
'info': { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-600', icon: <Info size={14} /> },
|
||||
};
|
||||
|
||||
const PROCEDURE_BUTTONS = [
|
||||
{ label: 'TROCA FIO', icon: <RotateCcw size={14} /> },
|
||||
{ label: 'ELÁSTICO', icon: <Sparkles size={14} /> },
|
||||
{ label: 'IPR', icon: <Scissors size={14} /> },
|
||||
{ label: 'RECOLAGEM', icon: <Wrench size={14} /> },
|
||||
{ label: 'AJUSTE TORQUE', icon: <RotateCcw size={14} /> },
|
||||
{ label: 'PROFILAXIA', icon: <Droplets size={14} /> },
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SECTION 1 – PATIENT HEADER (Dashboard fixo)
|
||||
================================================================ */
|
||||
const PatientHeader: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const age = calcAge(t.dataNascimento);
|
||||
const tipoLabel = tipoPacienteLabel(age);
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center font-bold text-lg flex-shrink-0">
|
||||
{t.pacienteNome.substring(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 uppercase">{t.pacienteNome} {age !== null && <span className="text-gray-500 font-normal">— {age} ANOS</span>}</h2>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{tipoLabel && <span className="text-[10px] font-bold bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full uppercase">{tipoLabel}</span>}
|
||||
<span className="text-[10px] font-bold bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full uppercase">{t.classificacao || t.diagnostico?.classeAngle}</span>
|
||||
<span className="text-[10px] font-bold bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full uppercase">FASE: {t.faseAtual}</span>
|
||||
<span className="text-[10px] font-bold bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full uppercase">FIO {t.fioAtual}</span>
|
||||
{t.cooperacao && <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${COOPERATION_COLORS[t.cooperacao] || ''}`}>COOPERAÇÃO {t.cooperacao.toUpperCase()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs uppercase font-bold text-gray-500">
|
||||
<div className="flex items-center gap-1"><Calendar size={14} /> ÚLTIMA: {t.ultimaConsulta ? new Date(t.ultimaConsulta).toLocaleDateString() : '—'}</div>
|
||||
<div className="flex items-center gap-1"><Calendar size={14} className="text-blue-500" /> PRÓX: {t.proximaConsulta ? new Date(t.proximaConsulta).toLocaleDateString() : '—'}</div>
|
||||
<div className="flex items-center gap-1"><Clock size={14} /> {t.mesesRestantes ?? '?'} MESES RESTANTES</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
{t.percentualConcluido != null && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-[10px] font-bold text-gray-500 uppercase mb-1">
|
||||
<span>PROGRESSO DO TRATAMENTO</span>
|
||||
<span>{t.percentualConcluido}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-2.5">
|
||||
<div className="bg-blue-500 h-2.5 rounded-full transition-all duration-500" style={{ width: `${t.percentualConcluido}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Flags */}
|
||||
{t.flags && t.flags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{t.flags.map((f, i) => {
|
||||
const s = FLAG_STYLES[f.tipo] || FLAG_STYLES.info;
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-bold uppercase ${s.bg} ${s.text}`}>
|
||||
{s.icon} {f.mensagem}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{t.responsavel && <p className="mt-2 text-[10px] text-gray-400 font-bold uppercase">RESPONSÁVEL: {t.responsavel}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 2 – TRIAGEM CLÍNICA
|
||||
================================================================ */
|
||||
const ClinicalTriage: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tr = t.triagem;
|
||||
if (!tr) return null;
|
||||
const age = calcAge(t.dataNascimento);
|
||||
const isAdult = age !== null && age >= 18;
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><Stethoscope size={16} className="text-purple-500" /> TRIAGEM CLÍNICA</h3>
|
||||
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm animate-in fade-in">
|
||||
<Field label="QUEIXA PRINCIPAL" value={tr.queixaPrincipal} />
|
||||
<Field label="HISTÓRICO MÉDICO" value={tr.historicoMedico} />
|
||||
<Field label="MEDICAÇÃO" value={tr.medicacao} />
|
||||
<Field label="ALERGIAS" value={tr.alergias} />
|
||||
<Field label="RESPIRAÇÃO BUCAL" value={tr.respiracaoBucal ? 'SIM' : 'NÃO'} />
|
||||
<Field label="HÁBITOS" value={tr.habitos} />
|
||||
{isAdult && (
|
||||
<>
|
||||
<div className="col-span-full border-t pt-3 mt-1">
|
||||
<span className="text-[10px] font-bold text-purple-500 uppercase">CAMPOS ADULTO</span>
|
||||
</div>
|
||||
<Field label="PERIODONTO" value={tr.periodonto} />
|
||||
<Field label="IMPLANTES" value={tr.implantes} />
|
||||
<Field label="RECESSÕES" value={tr.recessoes} />
|
||||
<Field label="MOBILIDADE" value={tr.mobilidade} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 3 – DIAGNÓSTICO
|
||||
================================================================ */
|
||||
const Diagnosis: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const d = t.diagnostico;
|
||||
if (!d) return null;
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-blue-500" /> DIAGNÓSTICO ORTODÔNTICO</h3>
|
||||
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-5 grid grid-cols-2 md:grid-cols-3 gap-4 text-sm animate-in fade-in">
|
||||
<Field label="CLASSE ANGLE" value={d.classeAngle} highlight />
|
||||
<Field label="PERFIL" value={d.perfil} />
|
||||
<Field label="APINHAMENTO" value={d.apinhamento} highlight={d.apinhamento === 'Severo'} />
|
||||
<Field label="MORDIDA" value={d.mordida} />
|
||||
<Field label="LINHA MÉDIA" value={d.linhaMedia} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 4 – PLANO DE TRATAMENTO
|
||||
================================================================ */
|
||||
const TreatmentPlan: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<button onClick={() => setOpen(!open)} className="w-full px-5 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2"><Heart size={16} className="text-pink-500" /> PLANO DE TRATAMENTO</h3>
|
||||
{open ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-5 space-y-3 text-sm animate-in fade-in">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Field label="APARELHO" value={t.tipoAparelho} highlight />
|
||||
<Field label="EXTRAÇÕES" value={t.extracoes} />
|
||||
<Field label="ELÁSTICOS" value={t.elasticos} />
|
||||
<Field label="IPR PLANEJADO" value={t.iprPlanejado} />
|
||||
<Field label="TEMPO ESTIMADO" value={t.tempoEstimado} />
|
||||
{t.valor != null && <Field label="VALOR" value={`R$ ${t.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`} />}
|
||||
{t.formaPagamento && <Field label="PAGAMENTO" value={t.formaPagamento} />}
|
||||
</div>
|
||||
{t.sequenciaFios && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 mt-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">SEQUÊNCIA DE FIOS</span>
|
||||
<p className="font-bold text-gray-800 mt-1 tracking-wide">{t.sequenciaFios}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 5 – TIMELINE INTELIGENTE
|
||||
================================================================ */
|
||||
const Timeline: React.FC<{ evolucoes: EvolucaoOrto[] }> = ({ evolucoes }) => {
|
||||
const sorted = [...evolucoes].sort((a, b) => new Date(b.data).getTime() - new Date(a.data).getTime());
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<Activity size={16} className="text-blue-500" /> LINHA DO TEMPO — {evolucoes.length} EVENTO{evolucoes.length !== 1 && 'S'}
|
||||
</h3>
|
||||
<div className="space-y-4 relative before:absolute before:left-[7px] before:top-2 before:bottom-2 before:w-0.5 before:bg-gray-200">
|
||||
{sorted.map((ev) => (
|
||||
<div key={ev.id} className="relative pl-7">
|
||||
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full z-10 border-2 ${ev.tipo === 'Falta' ? 'bg-red-500 border-red-300' : ev.tipo === 'Colagem' ? 'bg-green-500 border-green-300' : 'bg-white border-blue-500'}`}></div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex flex-wrap justify-between items-start gap-2 mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-900 text-sm">{new Date(ev.data).toLocaleDateString()}</span>
|
||||
<span className="text-[9px] font-bold bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full uppercase">{ev.tipo}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-blue-600 flex items-center gap-1 font-bold uppercase">
|
||||
PRÓX: {new Date(ev.proximoRetorno).toLocaleDateString()} <ArrowRight size={10} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 uppercase font-medium">{ev.procedimento}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{ev.fio && <span className="text-[9px] font-bold bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full uppercase">FIO: {ev.fio}</span>}
|
||||
{ev.elastico && <span className="text-[9px] font-bold bg-purple-50 text-purple-600 px-2 py-0.5 rounded-full uppercase">ELÁSTICO: {ev.elastico}</span>}
|
||||
{ev.procedimentos?.map((p, i) => <span key={i} className="text-[9px] font-bold bg-green-50 text-green-600 px-2 py-0.5 rounded-full uppercase">{p}</span>)}
|
||||
{ev.higiene != null && (
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full uppercase ${ev.higiene >= 7 ? 'bg-green-50 text-green-600' : ev.higiene >= 5 ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-red-600'}`}>
|
||||
HIGIENE: {ev.higiene}/10
|
||||
</span>
|
||||
)}
|
||||
{ev.cooperacao && <span className={`text-[9px] font-bold px-2 py-0.5 rounded-full uppercase ${COOPERATION_COLORS[ev.cooperacao] || ''}`}>COOP: {ev.cooperacao.toUpperCase()}</span>}
|
||||
</div>
|
||||
{ev.observacoes && <p className="text-[10px] text-gray-500 mt-2 italic uppercase">{ev.observacoes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 6 – NOVA EVOLUÇÃO
|
||||
================================================================ */
|
||||
const NewEvolution: React.FC<{ tratamentoId: string; currentFio?: string; onSaved: () => void }> = ({ tratamentoId, currentFio, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [tipo, setTipo] = useState<TipoEventoOrto>('Manutenção');
|
||||
const [fio, setFio] = useState(currentFio || '');
|
||||
const [elastico, setElastico] = useState('');
|
||||
const [selectedProcs, setSelectedProcs] = useState<string[]>([]);
|
||||
const [higiene, setHigiene] = useState(7);
|
||||
const [cooperacao, setCooperacao] = useState<CooperacaoOrto>('Boa');
|
||||
const [observacoes, setObservacoes] = useState('');
|
||||
const [proximoRetorno, setProximoRetorno] = useState('');
|
||||
|
||||
const toggleProc = (label: string) => {
|
||||
setSelectedProcs(prev => prev.includes(label) ? prev.filter(p => p !== label) : [...prev, label]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const procSummary = [tipo, fio && `FIO ${fio}`, elastico && `ELÁSTICO ${elastico}`, ...selectedProcs].filter(Boolean).join(' + ');
|
||||
try {
|
||||
await HybridBackend.saveEvolucaoOrto(tratamentoId, {
|
||||
tipo,
|
||||
procedimento: procSummary.toUpperCase(),
|
||||
proximoRetorno,
|
||||
fio: fio || undefined,
|
||||
elastico: elastico || undefined,
|
||||
procedimentos: selectedProcs.length > 0 ? selectedProcs : undefined,
|
||||
higiene,
|
||||
cooperacao,
|
||||
observacoes: observacoes.toUpperCase() || undefined,
|
||||
});
|
||||
toast.success('EVOLUÇÃO SALVA COM SUCESSO!');
|
||||
// Reset
|
||||
setTipo('Manutenção');
|
||||
setElastico('');
|
||||
setSelectedProcs([]);
|
||||
setHigiene(7);
|
||||
setCooperacao('Boa');
|
||||
setObservacoes('');
|
||||
setProximoRetorno('');
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('ERRO AO SALVAR EVOLUÇÃO.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<Plus size={16} className="text-green-500" /> NOVA EVOLUÇÃO
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO EVENTO</label>
|
||||
<select value={tipo} onChange={e => setTipo(e.target.value as TipoEventoOrto)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
|
||||
<option>Manutenção</option>
|
||||
<option>Troca de fio</option>
|
||||
<option>Colagem</option>
|
||||
<option>Emergência</option>
|
||||
<option>Falta</option>
|
||||
<option>Finalização</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">FIO</label>
|
||||
<select value={fio} onChange={e => setFio(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
|
||||
<option value="">— MANTER ATUAL —</option>
|
||||
<option>0.12</option>
|
||||
<option>0.14</option>
|
||||
<option>0.16</option>
|
||||
<option>0.18</option>
|
||||
<option>0.20x0.25</option>
|
||||
<option>0.19x0.25</option>
|
||||
<option>0.21x0.25</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">ELÁSTICO</label>
|
||||
<select value={elastico} onChange={e => setElastico(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
|
||||
<option value="">— NENHUM —</option>
|
||||
<option>CLASSE II</option>
|
||||
<option>CLASSE III</option>
|
||||
<option>VERTICAL</option>
|
||||
<option>CRUZADO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Procedure quick buttons */}
|
||||
<div className="mt-4">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">PROCEDIMENTOS REALIZADOS</label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{PROCEDURE_BUTTONS.map(pb => (
|
||||
<button
|
||||
key={pb.label}
|
||||
type="button"
|
||||
onClick={() => toggleProc(pb.label)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold uppercase border transition-all ${selectedProcs.includes(pb.label) ? 'bg-green-600 text-white border-green-600 shadow-sm' : 'bg-white text-gray-600 border-gray-300 hover:border-green-400 hover:bg-green-50'}`}
|
||||
>
|
||||
{pb.icon} {pb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 text-sm">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">HIGIENE (1–10)</label>
|
||||
<input type="range" min={1} max={10} value={higiene} onChange={e => setHigiene(Number(e.target.value))} className="w-full mt-2" />
|
||||
<div className="text-center font-bold text-lg mt-1">{higiene}<span className="text-gray-400 text-xs">/10</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">COOPERAÇÃO</label>
|
||||
<select value={cooperacao} onChange={e => setCooperacao(e.target.value as CooperacaoOrto)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1">
|
||||
<option>Boa</option>
|
||||
<option>Média</option>
|
||||
<option>Ruim</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">PRÓXIMO RETORNO</label>
|
||||
<input type="date" value={proximoRetorno} onChange={e => setProximoRetorno(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">OBSERVAÇÕES CLÍNICAS</label>
|
||||
<textarea value={observacoes} onChange={e => setObservacoes(e.target.value.toUpperCase())} placeholder="ANOTAÇÕES LIVRES..." className="w-full border border-gray-300 rounded-lg p-3 mt-1 h-20 uppercase-textarea text-sm" />
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} className="mt-4 w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
|
||||
<CheckCircle size={16} /> SALVAR EVOLUÇÃO
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 7 – COMPARATIVO FOTOGRÁFICO
|
||||
================================================================ */
|
||||
const PhotoCompare: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<Camera size={16} className="text-indigo-500" /> COMPARATIVO FOTOGRÁFICO
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 flex flex-col items-center justify-center text-center bg-gray-50 min-h-[160px]">
|
||||
<Camera size={32} className="text-gray-300 mb-2" />
|
||||
<span className="text-xs font-bold text-gray-400 uppercase">INÍCIO</span>
|
||||
{t.fotos?.find(f => f.tipo === 'inicio')?.data && (
|
||||
<span className="text-[10px] text-gray-400 mt-1">{new Date(t.fotos.find(f => f.tipo === 'inicio')!.data).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 flex flex-col items-center justify-center text-center bg-gray-50 min-h-[160px]">
|
||||
<Camera size={32} className="text-gray-300 mb-2" />
|
||||
<span className="text-xs font-bold text-gray-400 uppercase">ATUAL</span>
|
||||
{t.fotos?.find(f => f.tipo === 'atual')?.data && (
|
||||
<span className="text-[10px] text-gray-400 mt-1">{new Date(t.fotos.find(f => f.tipo === 'atual')!.data).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="mt-4 w-full border-2 border-dashed border-gray-300 hover:border-blue-400 hover:bg-blue-50 py-3 rounded-lg text-xs font-bold text-gray-500 hover:text-blue-600 uppercase transition-colors flex items-center justify-center gap-2">
|
||||
<Upload size={16} /> UPLOAD FOTOS
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 8 – FINALIZAÇÃO
|
||||
================================================================ */
|
||||
const Finalization: React.FC<{ t: TratamentoOrto; onFinalize: () => void }> = ({ t, onFinalize }) => {
|
||||
const toast = useToast();
|
||||
const [checks, setChecks] = useState({ alinhamento: false, oclusao: false, raizes: false, estetica: false });
|
||||
const [contencaoTipo, setContencaoTipo] = useState('CONTENÇÃO FIXA INFERIOR');
|
||||
const [superior, setSuperior] = useState(false);
|
||||
const [inferior, setInferior] = useState(true);
|
||||
const [tempoUso, setTempoUso] = useState('PERMANENTE');
|
||||
const allChecked = Object.values(checks).every(Boolean);
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!allChecked) {
|
||||
toast.error('PREENCHA TODOS OS CRITÉRIOS DO CHECKLIST.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.finalizarTratamentoOrto(t.id, { tipo: contencaoTipo, superior, inferior, tempoUso });
|
||||
toast.success('TRATAMENTO FINALIZADO COM SUCESSO!');
|
||||
onFinalize();
|
||||
} catch {
|
||||
toast.error('ERRO AO FINALIZAR TRATAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
if (t.status === 'Finalizado') {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-center">
|
||||
<CheckCircle size={32} className="text-green-500 mx-auto mb-2" />
|
||||
<h3 className="font-bold text-green-700 uppercase">TRATAMENTO FINALIZADO</h3>
|
||||
{t.contencaoTipo && <p className="text-xs text-green-600 mt-1 uppercase">CONTENÇÃO: {t.contencaoTipo}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<ShieldCheck size={16} className="text-green-500" /> FINALIZAÇÃO E CONTENÇÃO
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Checklist */}
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">CHECKLIST OBRIGATÓRIO</span>
|
||||
<div className="space-y-2 mt-2">
|
||||
{Object.entries({ alinhamento: 'ALINHAMENTO OK', oclusao: 'OCLUSÃO OK', raizes: 'RAÍZES PARALELAS', estetica: 'ESTÉTICA ACEITA' }).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={checks[key as keyof typeof checks]} onChange={() => setChecks(prev => ({ ...prev, [key]: !prev[key as keyof typeof checks] }))} className="w-4 h-4 rounded border-gray-300 text-green-600" />
|
||||
<span className="font-bold uppercase text-gray-700">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenção */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO CONTENÇÃO</label>
|
||||
<select value={contencaoTipo} onChange={e => setContencaoTipo(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1 text-sm">
|
||||
<option>CONTENÇÃO FIXA INFERIOR</option>
|
||||
<option>CONTENÇÃO FIXA SUPERIOR E INFERIOR</option>
|
||||
<option>PLACA DE CONTENÇÃO REMOVÍVEL</option>
|
||||
<option>ESSIX (ALINHADOR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={superior} onChange={() => setSuperior(!superior)} className="w-4 h-4 rounded" />
|
||||
<span className="font-bold uppercase text-gray-700">SUPERIOR</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={inferior} onChange={() => setInferior(!inferior)} className="w-4 h-4 rounded" />
|
||||
<span className="font-bold uppercase text-gray-700">INFERIOR</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">TEMPO DE USO</label>
|
||||
<select value={tempoUso} onChange={e => setTempoUso(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select mt-1 text-sm">
|
||||
<option>PERMANENTE</option>
|
||||
<option>6 MESES</option>
|
||||
<option>12 MESES</option>
|
||||
<option>24 MESES</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleFinalize} disabled={!allChecked} className={`mt-5 w-full py-3 rounded-lg font-bold uppercase text-sm transition-colors flex items-center justify-center gap-2 ${allChecked ? 'bg-green-600 hover:bg-green-700 text-white shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||
<CheckCircle size={16} /> FINALIZAR TRATAMENTO
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 9 – CONTENÇÃO — TERMO DE CIÊNCIA + VALOR
|
||||
================================================================ */
|
||||
const ContencaoTermo: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const toast = useToast();
|
||||
const [valorContencao, setValorContencao] = useState(350);
|
||||
const [aceite, setAceite] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<ClipboardCheck size={16} className="text-orange-500" /> CONTENÇÃO — TERMO E VALOR
|
||||
</h3>
|
||||
|
||||
{/* Termo de ciência */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={18} className="text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-amber-700 uppercase mb-1">TERMO DE CIÊNCIA</p>
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
O PACIENTE DECLARA ESTAR CIENTE DE QUE A <strong>CONTENÇÃO ORTODÔNTICA</strong> (FIXA E/OU REMOVÍVEL)
|
||||
É UM PROCEDIMENTO <strong>SEPARADO DO TRATAMENTO ORTODÔNTICO</strong> E <strong>NÃO ESTÁ INCLUÍDA NO VALOR
|
||||
DA MANUTENÇÃO MENSAL</strong>. O CUSTO DA CONTENÇÃO SERÁ COBRADO À PARTE, CONFORME VALORES
|
||||
ABAIXO INFORMADOS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">VALOR DA CONTENÇÃO (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={valorContencao}
|
||||
onChange={e => setValorContencao(Number(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm font-bold"
|
||||
min={0}
|
||||
step={50}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">VALOR COBRADO SEPARADAMENTE DA MENSALIDADE</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end">
|
||||
<label className="flex items-start gap-2 cursor-pointer bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aceite}
|
||||
onChange={() => setAceite(!aceite)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-orange-600 mt-0.5"
|
||||
/>
|
||||
<span className="text-xs font-bold uppercase text-gray-700 leading-relaxed">
|
||||
PACIENTE CIENTE E DE ACORDO COM A COBRANÇA SEPARADA DA CONTENÇÃO
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aceite && (
|
||||
<div className="mt-3 bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span className="text-xs font-bold text-green-700 uppercase">TERMO ACEITO — R$ {valorContencao.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 10 – SOLICITAÇÕES CLÍNICAS (LIMPEZA, CÁRIE, EXTRAÇÃO, RASPAGEM)
|
||||
================================================================ */
|
||||
const ClinicalRequests: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const toast = useToast();
|
||||
const [requests, setRequests] = useState<{ tipo: string; dente?: string; obs?: string }[]>([]);
|
||||
const [novoTipo, setNovoTipo] = useState('LIMPEZA / PROFILAXIA');
|
||||
const [novoDente, setNovoDente] = useState('');
|
||||
const [novoObs, setNovoObs] = useState('');
|
||||
|
||||
const REQUEST_TYPES = [
|
||||
'LIMPEZA / PROFILAXIA',
|
||||
'DENTE COM CÁRIE',
|
||||
'DENTE PARA EXTRAIR',
|
||||
'RASPAGEM PERIODONTAL',
|
||||
];
|
||||
|
||||
const addRequest = () => {
|
||||
setRequests(prev => [
|
||||
...prev,
|
||||
{ tipo: novoTipo, dente: novoDente.toUpperCase() || undefined, obs: novoObs.toUpperCase() || undefined }
|
||||
]);
|
||||
setNovoDente('');
|
||||
setNovoObs('');
|
||||
toast.success('SOLICITAÇÃO ADICIONADA!');
|
||||
};
|
||||
|
||||
const removeRequest = (index: number) => {
|
||||
setRequests(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<Stethoscope size={16} className="text-red-500" /> SOLICITAÇÕES CLÍNICAS
|
||||
</h3>
|
||||
|
||||
{/* Lista de solicitações já adicionadas */}
|
||||
{requests.length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{requests.map((req, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${req.tipo.includes('CÁRIE') ? 'bg-red-500' :
|
||||
req.tipo.includes('EXTRAIR') ? 'bg-orange-500' :
|
||||
req.tipo.includes('RASPAGEM') ? 'bg-purple-500' : 'bg-blue-500'
|
||||
}`}></span>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-gray-800 uppercase">{req.tipo}</span>
|
||||
{req.dente && <span className="text-[10px] text-gray-500 ml-2">DENTE: {req.dente}</span>}
|
||||
{req.obs && <p className="text-[10px] text-gray-400 italic">{req.obs}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => removeRequest(i)} className="text-gray-400 hover:text-red-500 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulário para nova solicitação */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">TIPO</label>
|
||||
<select value={novoTipo} onChange={e => setNovoTipo(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm">
|
||||
{REQUEST_TYPES.map(rt => <option key={rt}>{rt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">DENTE (OPCIONAL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={novoDente}
|
||||
onChange={e => setNovoDente(e.target.value)}
|
||||
placeholder="EX: 36, 14"
|
||||
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase">OBSERVAÇÃO</label>
|
||||
<input
|
||||
type="text"
|
||||
value={novoObs}
|
||||
onChange={e => setNovoObs(e.target.value)}
|
||||
placeholder="DETALHES..."
|
||||
className="w-full border border-gray-300 rounded-lg p-2.5 bg-white mt-1 text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={addRequest} className="mt-3 w-full border-2 border-dashed border-gray-300 hover:border-blue-400 hover:bg-blue-50 py-2.5 rounded-lg text-xs font-bold text-gray-500 hover:text-blue-600 uppercase transition-colors flex items-center justify-center gap-2">
|
||||
<Plus size={14} /> ADICIONAR SOLICITAÇÃO
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
SECTION 11 – RELATÓRIO DE MANUTENÇÕES (GERAR + EXPORTAR)
|
||||
================================================================ */
|
||||
const MaintenanceReport: React.FC<{ t: TratamentoOrto }> = ({ t }) => {
|
||||
const toast = useToast();
|
||||
|
||||
const generateReport = () => {
|
||||
const sorted = [...t.evolucoes].sort((a, b) => new Date(a.data).getTime() - new Date(b.data).getTime());
|
||||
const lines: string[] = [
|
||||
'════════════════════════════════════════════════════════',
|
||||
' RELATÓRIO DE MANUTENÇÕES ORTODÔNTICAS',
|
||||
'════════════════════════════════════════════════════════',
|
||||
'',
|
||||
`PACIENTE: ${t.pacienteNome}`,
|
||||
`APARELHO: ${t.tipoAparelho}`,
|
||||
`INÍCIO: ${new Date(t.dataInicio).toLocaleDateString()}`,
|
||||
`STATUS: ${t.status}`,
|
||||
`FASE ATUAL: ${t.faseAtual || '—'}`,
|
||||
`FIO ATUAL: ${t.fioAtual || '—'}`,
|
||||
`PROGRESSO: ${t.percentualConcluido ?? '—'}%`,
|
||||
'',
|
||||
'────────────────────────────────────────────────────────',
|
||||
` TOTAL DE ATENDIMENTOS: ${sorted.length}`,
|
||||
'────────────────────────────────────────────────────────',
|
||||
'',
|
||||
];
|
||||
|
||||
sorted.forEach((ev, i) => {
|
||||
lines.push(` #${i + 1} | ${new Date(ev.data).toLocaleDateString()} | ${ev.tipo}`);
|
||||
lines.push(` ${ev.procedimento}`);
|
||||
if (ev.fio) lines.push(` FIO: ${ev.fio}`);
|
||||
if (ev.elastico) lines.push(` ELÁSTICO: ${ev.elastico}`);
|
||||
if (ev.procedimentos?.length) lines.push(` PROC: ${ev.procedimentos.join(', ')}`);
|
||||
if (ev.higiene != null) lines.push(` HIGIENE: ${ev.higiene}/10`);
|
||||
if (ev.cooperacao) lines.push(` COOPERAÇÃO: ${ev.cooperacao}`);
|
||||
if (ev.observacoes) lines.push(` OBS: ${ev.observacoes}`);
|
||||
lines.push(` PRÓXIMO RETORNO: ${new Date(ev.proximoRetorno).toLocaleDateString()}`);
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
lines.push('────────────────────────────────────────────────────────');
|
||||
lines.push(` GERADO EM: ${new Date().toLocaleString()}`);
|
||||
lines.push(' SCOREODONTO — GESTÃO ORTODÔNTICA');
|
||||
lines.push('════════════════════════════════════════════════════════');
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const content = generateReport();
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `RELATORIO_ORTO_${t.pacienteNome.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('RELATÓRIO EXPORTADO COM SUCESSO!');
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const content = generateReport();
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
toast.success('RELATÓRIO COPIADO PARA ÁREA DE TRANSFERÊNCIA!');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-bold text-gray-700 uppercase text-sm flex items-center gap-2 mb-4">
|
||||
<FileDown size={16} className="text-teal-500" /> RELATÓRIO DE MANUTENÇÕES
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{t.evolucoes.length}</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase">ATENDIMENTOS</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{new Date(t.dataInicio).toLocaleDateString()}</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase">INÍCIO</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{t.percentualConcluido ?? '—'}%</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase">PROGRESSO</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{t.mesesRestantes ?? '—'}</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase">MESES REST.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<button onClick={handleExport} className="bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
|
||||
<FileDown size={16} /> EXPORTAR RELATÓRIO (.TXT)
|
||||
</button>
|
||||
<button onClick={handleCopy} className="bg-gray-600 hover:bg-gray-700 text-white py-3 rounded-lg font-bold uppercase text-sm transition-colors shadow-sm flex items-center justify-center gap-2">
|
||||
<ClipboardCheck size={16} /> COPIAR PARA ÁREA DE TRANSFERÊNCIA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
REUSABLE: Campo label + value
|
||||
================================================================ */
|
||||
const Field: React.FC<{ label: string; value?: string | null; highlight?: boolean }> = ({ label, value, highlight }) => (
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">{label}</span>
|
||||
<p className={`font-bold uppercase mt-0.5 ${highlight ? 'text-blue-600' : 'text-gray-800'}`}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ================================================================
|
||||
DETAIL PANEL — modal fullscreen com todas as seções
|
||||
================================================================ */
|
||||
const OrthoDetailPanel: React.FC<{ treatment: TratamentoOrto; onClose: () => void }> = ({ treatment, onClose }) => {
|
||||
const [t, setT] = useState(treatment);
|
||||
const { refresh: refreshList } = useHybridBackend<TratamentoOrto[]>(HybridBackend.getOrtoTreatments);
|
||||
|
||||
const handleEvolucaoSaved = useCallback(() => {
|
||||
// Re-fetch to update local state
|
||||
HybridBackend.getOrtoTreatments().then(all => {
|
||||
const updated = all.find(tr => tr.id === t.id);
|
||||
if (updated) setT(updated);
|
||||
});
|
||||
}, [t.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-gray-50 rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[94vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<div className="px-5 py-3 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-white">
|
||||
<button onClick={onClose} className="flex items-center gap-2 text-gray-500 hover:text-gray-800 text-xs font-bold uppercase transition-colors">
|
||||
<ArrowLeft size={16} /> VOLTAR À LISTA
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase ${t.status === 'Finalizado' ? 'bg-green-100 text-green-700' : t.status === 'Contenção' ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{t.status}
|
||||
</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-5">
|
||||
<PatientHeader t={t} />
|
||||
<ClinicalTriage t={t} />
|
||||
<Diagnosis t={t} />
|
||||
<TreatmentPlan t={t} />
|
||||
<Timeline evolucoes={t.evolucoes} />
|
||||
<NewEvolution tratamentoId={t.id} currentFio={t.fioAtual} onSaved={handleEvolucaoSaved} />
|
||||
<PhotoCompare t={t} />
|
||||
<ContencaoTermo t={t} />
|
||||
<ClinicalRequests t={t} />
|
||||
<MaintenanceReport t={t} />
|
||||
<Finalization t={t} onFinalize={() => { refreshList(); onClose(); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
MAIN VIEW — ORTHOVIEW (preservando listagem original)
|
||||
================================================================ */
|
||||
export const OrthoView: React.FC = () => {
|
||||
const { data: treatments, refresh } = useHybridBackend<TratamentoOrto[]>(HybridBackend.getOrtoTreatments);
|
||||
const [selectedTreatment, setSelectedTreatment] = useState<TratamentoOrto | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="GESTÃO ORTODÔNTICA">
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 text-xs font-bold uppercase transition-colors shadow-sm">
|
||||
<Plus size={18} /> NOVO TRATAMENTO
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{treatments?.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setSelectedTreatment(t)}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center font-bold text-sm">
|
||||
{t.pacienteNome.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 uppercase">{t.pacienteNome}</h3>
|
||||
<p className="text-[10px] text-gray-500 uppercase font-bold">
|
||||
{t.tipoAparelho} • INÍCIO: {new Date(t.dataInicio).toLocaleDateString()}
|
||||
{t.faseAtual && ` • FASE: ${t.faseAtual}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.percentualConcluido != null && (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${t.percentualConcluido}%` }}></div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-gray-500">{t.percentualConcluido}%</span>
|
||||
</div>
|
||||
)}
|
||||
{t.cooperacao && <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase hidden lg:inline-block ${COOPERATION_COLORS[t.cooperacao] || ''}`}>{t.cooperacao}</span>}
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase ${t.status === 'Finalizado' ? 'bg-green-100 text-green-700' : t.status === 'Contenção' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{t.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview timeline */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-bold text-gray-500 mb-3 flex items-center gap-2 uppercase">
|
||||
<Activity size={14} /> ÚLTIMAS EVOLUÇÕES
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{t.evolucoes.slice(-2).map((ev) => (
|
||||
<div key={ev.id} className="flex items-center gap-3 text-xs">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="font-bold text-gray-700">{new Date(ev.data).toLocaleDateString()}</span>
|
||||
<span className="text-gray-500 truncate uppercase">{ev.procedimento}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Flags preview */}
|
||||
{t.flags && t.flags.length > 0 && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
|
||||
{t.flags.map((f, i) => {
|
||||
const s = FLAG_STYLES[f.tipo] || FLAG_STYLES.info;
|
||||
return (
|
||||
<span key={i} className={`text-[9px] font-bold px-2 py-1 rounded-lg border uppercase ${s.bg} ${s.text}`}>
|
||||
{f.mensagem}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedTreatment && (
|
||||
<OrthoDetailPanel
|
||||
treatment={selectedTreatment}
|
||||
onClose={() => { setSelectedTreatment(null); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,660 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Plus, DollarSign, X, Printer, CreditCard, Rocket, Loader2, FolderOpen, CalendarDays, RadioTower, Pencil, Wallet, PlusCircle, Banknote, History, FileText, Calendar } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Agendamento } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Debounce hook to avoid excessive API calls on search
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
const AgendamentoModal: React.FC<{ patient: Paciente, onClose: () => void }> = ({ patient, onClose }) => {
|
||||
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const toast = useToast();
|
||||
|
||||
const [duration, setDuration] = useState(30);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [selectedTime, setSelectedTime] = useState('');
|
||||
const [selectedDentistId, setSelectedDentistId] = useState<string | undefined>(undefined);
|
||||
const [procedimento, setProcedimento] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (dentistas && !selectedDentistId) {
|
||||
setSelectedDentistId(dentistas[0]?.id);
|
||||
}
|
||||
}, [dentistas, selectedDentistId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const selectedDentist = dentistas?.find(d => d.id === selectedDentistId);
|
||||
|
||||
const generateTimeSlots = useCallback(() => {
|
||||
const slots: { time: string, available: boolean }[] = [];
|
||||
if (!selectedDentistId || !selectedDate) return slots;
|
||||
const startTime = 8 * 60, endTime = 18 * 60, lunchStart = 12 * 60, lunchEnd = 14 * 60;
|
||||
const existingAppointments = agendamentos?.filter(ag =>
|
||||
ag.dentistaId === selectedDentistId &&
|
||||
new Date(ag.start).toISOString().split('T')[0] === selectedDate
|
||||
).map(ag => ({
|
||||
start: new Date(ag.start).getHours() * 60 + new Date(ag.start).getMinutes(),
|
||||
end: new Date(ag.end).getHours() * 60 + new Date(ag.end).getMinutes(),
|
||||
})) || [];
|
||||
|
||||
for (let time = startTime; time < endTime; time += duration) {
|
||||
const slotStart = time, slotEnd = time + duration;
|
||||
if (slotEnd > endTime) continue;
|
||||
const isInLunch = (slotStart >= lunchStart && slotStart < lunchEnd) || (slotEnd > lunchStart && slotEnd <= lunchEnd) || (slotStart < lunchStart && slotEnd > lunchEnd);
|
||||
if (isInLunch) continue;
|
||||
const isOccupied = existingAppointments.some(app => (slotStart >= app.start && slotStart < app.end) || (slotEnd > app.start && slotEnd <= app.end) || (slotStart < app.start && slotEnd > app.end));
|
||||
if (!isOccupied) {
|
||||
const hour = Math.floor(slotStart / 60), minute = slotStart % 60;
|
||||
slots.push({ time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`, available: true });
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}, [selectedDentistId, selectedDate, agendamentos, duration]);
|
||||
|
||||
const timeSlots = generateTimeSlots();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedDate || !selectedTime || !selectedDentistId || !procedimento) {
|
||||
toast.error("PREENCHA TODOS OS CAMPOS PARA AGENDAR.");
|
||||
return;
|
||||
}
|
||||
const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + duration * 60000);
|
||||
try {
|
||||
await HybridBackend.salvarAgendamento({
|
||||
pacienteNome: patient.nome,
|
||||
dentistaId: selectedDentistId,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
status: 'Pendente',
|
||||
procedimento: procedimento.toUpperCase(),
|
||||
});
|
||||
toast.success(`AGENDAMENTO PARA ${patient.nome} SALVO!`);
|
||||
refreshAgendamentos();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR AGENDAMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<Calendar size={20} className="text-orange-500" /> AGENDAR CONSULTA
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">PACIENTE</label>
|
||||
<div className="w-full bg-gray-100 p-3 rounded-lg mt-1 font-bold text-gray-800 uppercase">{patient.nome}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DENTISTA RESPONSÁVEL</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<select value={selectedDentistId} onChange={(e) => setSelectedDentistId(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select">
|
||||
{dentistas?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
<div className="w-7 h-7 rounded-full border-2 border-white shadow flex-shrink-0" style={{ backgroundColor: selectedDentist?.corAgenda || '#ccc' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DATA</label>
|
||||
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)} className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TEMPO</label>
|
||||
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select">
|
||||
<option value={15}>15 MIN</option>
|
||||
<option value={30}>30 MIN</option>
|
||||
<option value={45}>45 MIN</option>
|
||||
<option value={60}>60 MIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">HORÁRIOS</label>
|
||||
<div className="grid grid-cols-6 gap-2 mt-2">
|
||||
{timeSlots.map(slot => (
|
||||
<button type="button" key={slot.time} onClick={() => setSelectedTime(slot.time)}
|
||||
className={`py-2 rounded-lg text-sm font-bold border transition-colors ${selectedTime === slot.time ? 'bg-blue-600 text-white border-blue-600' : 'bg-white border-gray-300 hover:border-blue-500'}`}>
|
||||
{slot.time}
|
||||
</button>
|
||||
))}
|
||||
{timeSlots.length === 0 && <p className="col-span-6 text-center text-xs text-gray-500 p-4 bg-gray-50 rounded-lg">NENHUM HORÁRIO DISPONÍVEL PARA ESTA DATA/DURAÇÃO.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">PROCEDIMENTO</label>
|
||||
<input type="text" value={procedimento} onChange={(e) => setProcedimento(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO..."
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 uppercase-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg shadow-sm transition-colors text-sm uppercase">CONFIRMAR</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
// ----------- NOVO PACIENTE MODAL -----------
|
||||
const NovoPacienteModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState({ nome: '', cpf: '', telefone: '', dataNascimento: '', convenio: '', email: '' });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.nome || !form.cpf) {
|
||||
toast.error('PREENCHA NOME E CPF.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.savePaciente({ ...form, id: Math.random().toString() } as any);
|
||||
toast.success('PACIENTE CADASTRADO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO CADASTRAR PACIENTE.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<Plus size={18} className="text-green-500" /> NOVO PACIENTE
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME COMPLETO *</label>
|
||||
<input type="text" required value={form.nome}
|
||||
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 font-semibold" placeholder="NOME COMPLETO" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CPF *</label>
|
||||
<input type="text" required value={form.cpf}
|
||||
onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="000.000.000-00" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TELEFONE</label>
|
||||
<input type="text" value={form.telefone}
|
||||
onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="(XX) XXXXX-XXXX" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DATA NASCIMENTO</label>
|
||||
<input type="date" value={form.dataNascimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CONVÊNIO</label>
|
||||
<input type="text" value={form.convenio}
|
||||
onChange={e => setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="EX: CASSEMS" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">E-MAIL</label>
|
||||
<input type="email" value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" placeholder="EMAIL@EXEMPLO.COM" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-sm uppercase flex items-center gap-2">
|
||||
<Plus size={16} /> CADASTRAR
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------- HISTORICO MODAL -----------
|
||||
const HistoricoModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
|
||||
const { data: agendamentos, isLoading } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const meus = agendamentos?.filter(a => a.pacienteNome === patient.nome) || [];
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase">HISTÓRICO — {patient.nome}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="p-4 max-h-96 overflow-y-auto space-y-2">
|
||||
{isLoading && <p className="text-center text-gray-400 text-sm">CARREGANDO...</p>}
|
||||
{!isLoading && meus.length === 0 && <p className="text-center text-gray-400 text-sm py-6">NENHUM AGENDAMENTO ENCONTRADO.</p>}
|
||||
{meus.map(a => (
|
||||
<div key={a.id} className="flex items-center justify-between border border-gray-100 rounded-lg p-3 bg-gray-50">
|
||||
<div>
|
||||
<p className="font-bold text-gray-800 text-sm uppercase">{a.procedimento}</p>
|
||||
<p className="text-xs text-gray-500">{new Date(a.start).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded-full uppercase ${a.status === 'Confirmado' ? 'bg-green-100 text-green-700' : a.status === 'Pendente' ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{a.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 flex justify-end">
|
||||
<button onClick={onClose} className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">FECHAR</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------- EDITAR PACIENTE MODAL -----------
|
||||
const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; onSaved: () => void }> = ({ patient, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState({ ...patient });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await HybridBackend.updatePaciente(form);
|
||||
toast.success('PACIENTE ATUALIZADO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO SALVAR.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="p-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<Pencil size={18} className="text-blue-500" /> EDITAR PACIENTE
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME COMPLETO</label>
|
||||
<input type="text" required value={form.nome}
|
||||
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CPF</label>
|
||||
<input type="text" value={form.cpf}
|
||||
onChange={e => setForm(f => ({ ...f, cpf: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TELEFONE</label>
|
||||
<input type="text" value={form.telefone}
|
||||
onChange={e => setForm(f => ({ ...f, telefone: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">DATA NASCIMENTO</label>
|
||||
<input type="date" value={form.dataNascimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataNascimento: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CONVÊNIO</label>
|
||||
<input type="text" value={form.convenio}
|
||||
onChange={e => setForm(f => ({ ...f, convenio: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg text-sm uppercase">SALVAR</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const PatientsView: React.FC = () => {
|
||||
const [busca, setBusca] = useState('');
|
||||
const debouncedBusca = useDebounce(busca, 500);
|
||||
|
||||
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedBusca), [debouncedBusca]);
|
||||
const { data: pacientes, isLoading, error, refresh } = useHybridBackend<Paciente[]>(fetcher, [debouncedBusca]);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [modal, setModal] = useState<{ type: string | null; patient: Paciente | null }>({ type: null, patient: null });
|
||||
const [isNovoPacienteOpen, setIsNovoPacienteOpen] = useState(false);
|
||||
|
||||
const closeModal = useCallback(() => setModal({ type: null, patient: null }), []);
|
||||
|
||||
// Effect to handle Esc key to close modals
|
||||
useEffect(() => {
|
||||
const isModalActive = modal.type !== null && modal.type !== 'agendamento' && modal.type !== 'historico' && modal.type !== 'editar';
|
||||
if (!isModalActive) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeModal(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [modal.type, closeModal]);
|
||||
|
||||
// Asaas form state
|
||||
const [asaasForm, setAsaasForm] = useState({ tratamento: '', valorTotal: '', desconto: '', entrada: '', parcelas: 1, vencimento: new Date().toISOString().split('T')[0] });
|
||||
useEffect(() => {
|
||||
if (modal.type === 'asaas') {
|
||||
setAsaasForm({ tratamento: '', valorTotal: '', desconto: '', entrada: '', parcelas: 1, vencimento: new Date().toISOString().split('T')[0] });
|
||||
}
|
||||
}, [modal.type, modal.patient]);
|
||||
|
||||
const handleAsaasFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setAsaasForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const valorTotalNum = parseFloat(asaasForm.valorTotal) || 0;
|
||||
const descontoNum = parseFloat(asaasForm.desconto) || 0;
|
||||
const entradaNum = parseFloat(asaasForm.entrada) || 0;
|
||||
const saldoAParcelar = valorTotalNum - descontoNum - entradaNum;
|
||||
const valorParcela = saldoAParcelar > 0 && asaasForm.parcelas > 0 ? saldoAParcelar / Number(asaasForm.parcelas) : 0;
|
||||
|
||||
const handleDocGeneration = () => {
|
||||
toast.success(`DOCUMENTO GERADO E SALVO NO DRIVE PARA ${modal.patient?.nome}!`);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleAsaasSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.success(`COBRANÇA GERADA NO ASAAS PARA ${modal.patient?.nome}!`);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const displayedPacientes = busca ? pacientes : pacientes?.slice(-3).reverse();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
<PageHeader title="PACIENTES">
|
||||
<button onClick={() => setIsNovoPacienteOpen(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm">
|
||||
<Plus size={18} />
|
||||
NOVO PACIENTE
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white p-4 rounded-xl border border-gray-200 shadow-sm mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="DIGITE NOME OU CPF PARA BUSCAR..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none uppercase-input"
|
||||
value={busca}
|
||||
onChange={(e) => setBusca(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!busca && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase">
|
||||
ÚLTIMOS CADASTRADOS ({pacientes?.length ?? 0})
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-y-auto pb-10">
|
||||
{isLoading && <div className="col-span-3 text-center p-10 text-gray-500 uppercase font-bold flex justify-center items-center gap-2"><Loader2 className="animate-spin" /> CARREGANDO DADOS DO MYSQL...</div>}
|
||||
{error && <div className="col-span-3 text-center p-10 text-red-600 uppercase font-bold">ERRO AO CARREGAR: {error.message}</div>}
|
||||
|
||||
{displayedPacientes && displayedPacientes.map(p => (
|
||||
<div key={p.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col">
|
||||
<div className="p-5 flex-grow flex flex-col">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-bold text-blue-700 text-lg truncate pr-2 uppercase">{p.nome}</h3>
|
||||
{p.convenio && (
|
||||
<span className="bg-green-100 text-green-800 text-xs font-bold px-2.5 py-1 rounded-full uppercase shrink-0">{p.convenio}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 flex-grow">
|
||||
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
||||
<label className="text-gray-400 text-[10px] font-bold uppercase">CPF</label>
|
||||
<p className="text-gray-800 font-semibold text-sm truncate">{p.cpf}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
||||
<label className="text-gray-400 text-[10px] font-bold uppercase">TEL</label>
|
||||
<p className="text-gray-800 font-semibold text-sm truncate">{p.telefone}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
||||
<label className="text-gray-400 text-[10px] font-bold uppercase">NASC</label>
|
||||
<p className="text-gray-800 font-semibold text-sm truncate">{p.dataNascimento}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50/70 border border-gray-200/50 rounded-lg p-2">
|
||||
<label className="text-gray-400 text-[10px] font-bold uppercase">CONV</label>
|
||||
<p className="text-gray-800 font-semibold text-sm truncate">{p.convenio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-t border-gray-200 mt-auto">
|
||||
<button onClick={() => setModal({ type: 'clinical', patient: p })} className="w-1/2 flex items-center justify-center gap-2 py-3 text-blue-600 hover:bg-blue-50 rounded-bl-xl text-sm font-bold transition-colors uppercase">
|
||||
<FolderOpen size={16} /> Clínico
|
||||
</button>
|
||||
<div className="border-l border-gray-200"></div>
|
||||
<button onClick={() => setModal({ type: 'financial', patient: p })} className="w-1/2 flex items-center justify-center gap-2 py-3 text-green-700 hover:bg-green-50 rounded-br-xl text-sm font-bold transition-colors uppercase">
|
||||
<DollarSign size={16} /> Financeiro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* NOVO PACIENTE MODAL */}
|
||||
{isNovoPacienteOpen && (
|
||||
<NovoPacienteModal onClose={() => setIsNovoPacienteOpen(false)} onSaved={refresh} />
|
||||
)}
|
||||
|
||||
{/* EDITAR PACIENTE MODAL */}
|
||||
{modal.type === 'editar' && modal.patient && (
|
||||
<EditarPacienteModal patient={modal.patient} onClose={closeModal} onSaved={refresh} />
|
||||
)}
|
||||
|
||||
{/* HISTORICO MODAL */}
|
||||
{modal.type === 'historico' && modal.patient && (
|
||||
<HistoricoModal patient={modal.patient} onClose={closeModal} />
|
||||
)}
|
||||
|
||||
{/* MODAL MENU CLÍNICO */}
|
||||
{modal.type === 'clinical' && modal.patient && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><FolderOpen size={28} className="text-gray-400" />Menu Clínico</div>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-8 font-bold uppercase">{modal.patient.nome}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button onClick={() => setModal({ type: 'agendamento', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-orange-400 hover:bg-orange-50/50 transition-all group shadow-sm"><CalendarDays size={32} className="text-orange-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">AGENDAR</span></button>
|
||||
<button onClick={() => setModal({ type: 'doc-receita', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-red-400 hover:bg-red-50/50 transition-all group shadow-sm"><FileText size={32} className="text-red-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">RECEITA</span></button>
|
||||
<button onClick={() => setModal({ type: 'doc-raiox', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:bg-purple-50/50 transition-all group shadow-sm"><RadioTower size={32} className="text-purple-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">RAIO-X</span></button>
|
||||
<button onClick={() => setModal({ type: 'editar', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Pencil size={32} className="text-blue-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">EDITAR DADOS</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL DE AGENDAMENTO */}
|
||||
{modal.type === 'agendamento' && modal.patient && (
|
||||
<AgendamentoModal patient={modal.patient} onClose={closeModal} />
|
||||
)}
|
||||
|
||||
{/* MODAL DOCUMENTOS */}
|
||||
{(modal.type === 'doc-receita' || modal.type === 'doc-raiox') && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md w-full animate-in fade-in zoom-in-50 duration-200">
|
||||
<h3 className="text-xl font-bold mb-4 uppercase">GERAR {modal.type === 'doc-receita' ? 'RECEITA' : 'PEDIDO DE RAIO-X'}</h3>
|
||||
<p className="text-gray-600 mb-4 text-sm font-bold uppercase">O SISTEMA IRÁ GERAR UM DOCUMENTO NO GOOGLE DOCS USANDO O MODELO PADRÃO.</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={closeModal} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg text-xs font-bold uppercase">CANCELAR</button>
|
||||
<button onClick={handleDocGeneration} className="px-4 py-2 bg-gray-800 text-white rounded-lg flex items-center gap-2 text-xs font-bold uppercase"><Printer size={16} /> GERAR PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL MENU FINANCEIRO */}
|
||||
{modal.type === 'financial' && modal.patient && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center gap-3 text-gray-700 font-bold text-xl"><Wallet size={28} className="text-gray-400" />Menu Financeiro</div>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-8 font-bold uppercase">{modal.patient.nome}</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<button onClick={() => toast.info?.('Em breve: Lançar Procedimento')} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-green-400 hover:bg-green-50/50 transition-all group shadow-sm"><PlusCircle size={32} className="text-green-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">LANÇAR</span></button>
|
||||
<button onClick={() => setModal({ type: 'asaas', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Banknote size={32} className="text-blue-600 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">ASAAS</span></button>
|
||||
<button onClick={() => setModal({ type: 'historico', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-gray-400 hover:bg-gray-50/50 transition-all group shadow-sm"><History size={32} className="text-gray-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">HISTÓRICO</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASAAS MODAL */}
|
||||
{modal.type === 'asaas' && modal.patient && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm overflow-y-auto p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl animate-in fade-in zoom-in-50 duration-200">
|
||||
<form onSubmit={handleAsaasSubmit}>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<span className="w-6 h-4 bg-yellow-400 rounded-sm flex items-center justify-center text-yellow-800">
|
||||
<CreditCard size={14} />
|
||||
</span>
|
||||
PARCELAMENTO INTELIGENTE (ASAAS)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Cliente: <span className="font-bold uppercase">{modal.patient.nome}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={closeModal} className="text-gray-400 hover:text-gray-600"><X size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">TRATAMENTO</label>
|
||||
<input type="text" name="tratamento" value={asaasForm.tratamento} onChange={handleAsaasFormChange} placeholder="EX: ORTODONTIA COMPLETA" className="w-full border border-gray-300 rounded-md p-2 text-sm uppercase-input" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">VALOR TOTAL (R$)</label>
|
||||
<input type="number" step="0.01" name="valorTotal" value={asaasForm.valorTotal} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">DESCONTO (R$)</label>
|
||||
<input type="number" step="0.01" name="desconto" value={asaasForm.desconto} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">ENTRADA (R$)</label>
|
||||
<input type="number" step="0.01" name="entrada" value={asaasForm.entrada} onChange={handleAsaasFormChange} placeholder="0,00" className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
||||
</div>
|
||||
<div className="bg-blue-50/70 p-3 rounded-md flex justify-between items-center border border-blue-100/50">
|
||||
<span className="font-semibold text-blue-700 text-sm">SALDO A PARCELAR:</span>
|
||||
<span className="font-bold text-blue-700 text-lg">{saldoAParcelar.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">PARCELAS (ATÉ 48X)</label>
|
||||
<select name="parcelas" value={asaasForm.parcelas} onChange={handleAsaasFormChange} className="w-full border border-gray-300 rounded-md p-2 text-sm bg-white">
|
||||
{Array.from({ length: 48 }, (_, i) => i + 1).map(num => (
|
||||
<option key={num} value={num}>{num}x</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">VENCIMENTO 1ª PARCELA</label>
|
||||
<input type="date" name="vencimento" value={asaasForm.vencimento} onChange={handleAsaasFormChange} className="w-full border border-gray-300 rounded-md p-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-blue-600 font-bold text-lg py-2">
|
||||
{asaasForm.parcelas}x DE {valorParcela.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button type="button" onClick={closeModal} className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg shadow-sm flex items-center justify-center gap-2 transition-colors text-sm uppercase">
|
||||
<Rocket size={16} />
|
||||
GERAR CARNÊ/PIX
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
export const PublicContactForm: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
sobrenome: '',
|
||||
cpf: '',
|
||||
whatsapp: '',
|
||||
tipo: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
if(!formData.tipo) {
|
||||
toast.error("POR FAVOR, SELECIONE PARTICULAR OU PLANO.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cpfExists = await HybridBackend.checkCpfExists(formData.cpf);
|
||||
if (cpfExists) {
|
||||
toast.error("CPF JÁ CADASTRADO. ENTRE EM CONTATO PELO WHATSAPP.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await HybridBackend.saveLead({
|
||||
nome: formData.nome,
|
||||
sobrenome: formData.sobrenome,
|
||||
cpf: formData.cpf,
|
||||
whatsapp: formData.whatsapp,
|
||||
tipoInteresse: formData.tipo as any
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
toast.error("ERRO AO ENVIAR DADOS. TENTE NOVAMENTE.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value.toUpperCase() });
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white p-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle size={32}/>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-amber-900 uppercase">MENSAGEM ENVIADA!</h2>
|
||||
<p className="text-amber-800 mt-2 uppercase font-bold text-sm">NOSSA EQUIPE ENTRARÁ EM CONTATO EM BREVE.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-center font-bold text-xl mb-6 text-black uppercase">DEIXE SEU CONTATO</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
name="nome"
|
||||
placeholder="NOME"
|
||||
className="w-full border border-amber-700 rounded-lg p-3 text-gray-800 outline-none focus:ring-2 focus:ring-amber-500 placeholder-gray-500 uppercase-input"
|
||||
required
|
||||
value={formData.nome}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="sobrenome"
|
||||
placeholder="SOBRENOME"
|
||||
className="w-full border border-amber-700 rounded-lg p-3 text-gray-800 outline-none focus:ring-2 focus:ring-amber-500 placeholder-gray-500 uppercase-input"
|
||||
required
|
||||
value={formData.sobrenome}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="cpf"
|
||||
placeholder="CPF"
|
||||
className="w-full border border-amber-700 rounded-lg p-3 text-gray-800 outline-none focus:ring-2 focus:ring-amber-500 placeholder-gray-500 uppercase-input"
|
||||
required
|
||||
value={formData.cpf}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="whatsapp"
|
||||
placeholder="WHATSAPP"
|
||||
className="w-full border border-amber-700 rounded-lg p-3 text-gray-800 outline-none focus:ring-2 focus:ring-amber-500 placeholder-gray-500 uppercase-input"
|
||||
required
|
||||
value={formData.whatsapp}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, tipo: 'Particular'})}
|
||||
className={`flex-1 py-3 rounded-lg font-bold uppercase text-sm transition-colors border ${formData.tipo === 'Particular' ? 'bg-amber-800 text-white border-amber-800' : 'bg-amber-600 text-white border-amber-600 hover:bg-amber-700'}`}
|
||||
>
|
||||
PARTICULAR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, tipo: 'Plano'})}
|
||||
className={`flex-1 py-3 rounded-lg font-bold uppercase text-sm transition-colors border ${formData.tipo === 'Plano' ? 'bg-purple-800 text-white border-purple-800' : 'bg-purple-600 text-white border-purple-600 hover:bg-purple-700'}`}
|
||||
>
|
||||
PLANO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-gray-800 text-white font-bold rounded-lg mt-6 shadow-sm hover:opacity-90 transition-opacity uppercase disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'VERIFICANDO...' : 'ENVIAR'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TrendingUp, Users, Calendar, DollarSign, ArrowUpRight, ArrowDownRight, Printer, Filter, Loader2, Download, BarChart3, PieChart, Activity } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
interface ReportData {
|
||||
financeiro: {
|
||||
receita: number;
|
||||
despesa: number;
|
||||
totalTransacoes: number;
|
||||
};
|
||||
producao: {
|
||||
dentista: string;
|
||||
totalAgendamentos: number;
|
||||
concluidos: number;
|
||||
faltas: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ReportsView: React.FC = () => {
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const [startDate, setStartDate] = useState(new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
const { data, isLoading, error, refresh } = useHybridBackend<ReportData>(() =>
|
||||
activeWorkspace ? HybridBackend.getProductivityReport(activeWorkspace.id, startDate, endDate) : Promise.resolve(null),
|
||||
[activeWorkspace?.id, startDate, endDate]
|
||||
);
|
||||
|
||||
const receita = data?.financeiro?.receita || 0;
|
||||
const despesa = data?.financeiro?.despesa || 0;
|
||||
const saldo = receita - despesa;
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!data) return;
|
||||
let csv = "Dentista,Total,Concluidos,Faltas,Taxa Ocupacao\n";
|
||||
data.producao.forEach(p => {
|
||||
const taxa = p.totalAgendamentos > 0 ? ((p.concluidos / p.totalAgendamentos) * 100).toFixed(1) : 0;
|
||||
csv += `${p.dentista},${p.totalAgendamentos},${p.concluidos},${p.faltas},${taxa}%\n`;
|
||||
});
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `relatorio_producao_${startDate}_${endDate}.csv`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
<PageHeader title="RELATÓRIOS & PERFORMANCE">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => window.print()} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<Printer size={16} /> <span className="hidden sm:inline">IMPRIMIR</span>
|
||||
</button>
|
||||
<button onClick={exportToCSV} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<Download size={16} /> <span className="hidden sm:inline">EXPORTAR</span>
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Calendar size={12} /> INÍCIO DO PERÍODO
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-bold text-gray-700 focus:ring-2 focus:ring-blue-100 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Calendar size={12} /> FIM DO PERÍODO
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-bold text-gray-700 focus:ring-2 focus:ring-blue-100 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={refresh} className="bg-blue-600 text-white px-6 py-3 rounded-xl font-black text-xs uppercase hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<Filter size={16} /> FILTRAR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Loader2 className="animate-spin text-blue-600" size={40} />
|
||||
<p className="text-xs font-black text-gray-400 uppercase tracking-widest">Processando dados clínicos...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Financial Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm overflow-hidden relative group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-110 transition-transform">
|
||||
<DollarSign size={80} className="text-green-600" />
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">RECEITA NO PERÍODO</h4>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-black text-gray-900">R$ {receita.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
||||
<span className="text-green-500 text-[10px] font-black flex items-center"><ArrowUpRight size={12} /> +2.4%</span>
|
||||
</div>
|
||||
<div className="mt-4 w-full bg-gray-50 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-green-500 h-full w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm overflow-hidden relative group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-110 transition-transform">
|
||||
<TrendingUp size={80} className="text-red-600" />
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">DESPESA NO PERÍODO</h4>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-black text-gray-900">R$ {despesa.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
|
||||
<span className="text-red-500 text-[10px] font-black flex items-center"><ArrowDownRight size={12} /> -1.2%</span>
|
||||
</div>
|
||||
<div className="mt-4 w-full bg-gray-50 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-red-500 h-full w-[30%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm overflow-hidden relative group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-110 transition-transform">
|
||||
<Activity size={80} className="text-blue-600" />
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">SALDO OPERACIONAL</h4>
|
||||
<div className="mt-2">
|
||||
<span className={`text-2xl font-black ${saldo >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
R$ {saldo.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-[10px] font-bold text-gray-400">LIQUIDEZ ATUAL DA UNIDADE</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm overflow-hidden relative group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-110 transition-transform">
|
||||
<Users size={80} className="text-purple-600" />
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">TRANSAÇÕES</h4>
|
||||
<div className="mt-2">
|
||||
<span className="text-2xl font-black text-gray-900">{data?.financeiro?.totalTransacoes || 0}</span>
|
||||
</div>
|
||||
<p className="mt-4 text-[10px] font-bold text-gray-400">MOVIMENTAÇÕES FINANCEIRAS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Productivity Table */}
|
||||
<div className="bg-white rounded-3xl border border-gray-100 shadow-xl overflow-hidden">
|
||||
<div className="px-8 py-6 bg-gradient-to-r from-gray-50 to-white border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<BarChart3 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-gray-900 uppercase">PRODUTIVIDADE POR DENTISTA</h3>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">DESEMPENHO CLÍNICO NO PERÍODO</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-full">
|
||||
<PieChart size={16} className="text-blue-600" />
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase">Visão por Especialista</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50/50">
|
||||
<tr>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">ESPECIALISTA</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">TOTAL APPTS</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">EFETIVADOS</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">FALTAS</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">TAXA OCUPAÇÃO</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest">STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{(data?.producao || []).map((p, idx) => {
|
||||
const occupancy = p.totalAgendamentos > 0 ? (p.concluidos / p.totalAgendamentos) * 100 : 0;
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-blue-50/30 transition-colors group">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 font-black text-xs group-hover:bg-blue-600 group-hover:text-white transition-all">
|
||||
{p.dentista.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-bold text-gray-900 uppercase text-xs">{p.dentista}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 font-bold text-gray-600 text-xs">{p.totalAgendamentos}</td>
|
||||
<td className="px-8 py-5 font-bold text-green-600 text-xs">{p.concluidos}</td>
|
||||
<td className="px-8 py-5 font-bold text-red-500 text-xs">{p.faltas}</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-3 min-w-[120px]">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ${occupancy > 70 ? 'bg-green-500' : occupancy > 40 ? 'bg-blue-500' : 'bg-orange-400'}`}
|
||||
style={{ width: `${occupancy}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-gray-400">{occupancy.toFixed(0)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
{occupancy > 75 ? (
|
||||
<span className="px-2 py-1 bg-green-50 text-green-600 text-[9px] font-black rounded-lg uppercase border border-green-100">Alta Performance</span>
|
||||
) : occupancy > 30 ? (
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-600 text-[9px] font-black rounded-lg uppercase border border-blue-100">Estável</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-50 text-gray-400 text-[9px] font-black rounded-lg uppercase border border-gray-100">Baixa Ocupação</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, Terminal, Play, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
export const UpdateDbView: React.FC = () => {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installLogs, setInstallLogs] = useState<string[]>([]);
|
||||
const [finished, setFinished] = useState(false);
|
||||
|
||||
const runInstallScript = async () => {
|
||||
setInstalling(true);
|
||||
setInstallLogs([]);
|
||||
setFinished(false);
|
||||
|
||||
// Simula a execução do script sh/update_db.sh
|
||||
await HybridBackend.runInstallScript((log) => {
|
||||
setInstallLogs(prev => [...prev, log]);
|
||||
});
|
||||
|
||||
setFinished(true);
|
||||
setInstalling(false);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
window.location.href = './'; // Volta para a raiz
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl bg-white rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="bg-gray-100 p-6 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-600 rounded-lg flex items-center justify-center text-white">
|
||||
<Database size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 uppercase">SCOREODONTO ADMIN</h1>
|
||||
<p className="text-gray-500 text-xs font-bold uppercase">FERRAMENTA DE ATUALIZAÇÃO DE BANCO</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleGoBack} className="text-gray-500 hover:text-gray-800 flex items-center gap-1 text-xs font-bold uppercase">
|
||||
<ArrowLeft size={16}/> VOLTAR AO SISTEMA
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{!installing && installLogs.length === 0 ? (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 bg-amber-100 text-amber-600 rounded-full flex items-center justify-center mx-auto">
|
||||
<Terminal size={40} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800 uppercase">SCRIPT DE ATUALIZAÇÃO (SH/UPDATE_DB.SH)</h2>
|
||||
<p className="text-gray-500 text-sm mt-2 max-w-md mx-auto uppercase">
|
||||
ESTA AÇÃO IRÁ LER A PLANILHA "DADOS PACIENTES", GERAR UM BACKUP SQL E ATUALIZAR AS TABELAS DO MYSQL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 text-green-400 p-4 rounded-lg font-mono text-sm text-left max-w-md mx-auto shadow-inner">
|
||||
$ ./sh/update_db.sh --force-sync
|
||||
</div>
|
||||
<button
|
||||
onClick={runInstallScript}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-4 rounded-lg font-bold uppercase flex items-center gap-3 mx-auto transition-all hover:scale-105 shadow-lg"
|
||||
>
|
||||
<Play size={20} fill="currentColor" />
|
||||
EXECUTAR ATUALIZAÇÃO
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<Terminal size={18} /> TERMINAL DE SAÍDA
|
||||
</h3>
|
||||
{finished && (
|
||||
<span className="text-green-600 text-xs font-bold uppercase flex items-center gap-1">
|
||||
<CheckCircle size={14} /> PROCESSO FINALIZADO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-900 text-green-500 font-mono text-xs p-6 rounded-lg h-80 overflow-y-auto shadow-inner border-4 border-gray-800">
|
||||
{installLogs.map((log, i) => (
|
||||
<div key={i} className="mb-1">{log}</div>
|
||||
))}
|
||||
{installing && (
|
||||
<div className="animate-pulse mt-2">_</div>
|
||||
)}
|
||||
</div>
|
||||
{finished && (
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="w-full py-3 bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold rounded-lg uppercase transition-colors"
|
||||
>
|
||||
VOLTAR PARA O LOGIN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user