feat: migrate ScoreOdonto CRM to Docker, PostgreSQL, and DragonflyDB

This commit is contained in:
Antigravity AI Agent
2026-05-13 07:13:41 +02:00
parent 4917d03bdf
commit 606da6de97
153 changed files with 35678 additions and 56 deletions
+5 -21
View File
@@ -1,24 +1,8 @@
FROM node:20-slim AS builder
FROM node:20-slim
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci
COPY package*.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:20-slim AS runner
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
RUN ln -s /app/plugins /plugins && \
ln -s /app /app/backend && \
ln -s /app/dist /app/src
EXPOSE 8008
EXPOSE 8018
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
CMD ["node", "server.js"]
+18
View File
@@ -0,0 +1,18 @@
{
"name": "scoreodonto-api",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"googleapis": "^171.4.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.11.3",
"ioredis": "^5.4.1"
}
}
+143
View File
@@ -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)
};
+68
View File
@@ -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
}
+1578
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+349
View File
@@ -0,0 +1,349 @@
-- =============================================================================
-- PostgreSQL Database Initialization Script for ScoreOdonto CRM
-- Conversion from MySQL with corrected encodings and standard constraints
-- =============================================================================
-- Clean up existing tables (with cascade for safety)
DROP TABLE IF EXISTS vinculos CASCADE;
DROP TABLE IF EXISTS usuarios CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS procedimentos_valores_planos CASCADE;
DROP TABLE IF EXISTS procedimentos CASCADE;
DROP TABLE IF EXISTS planos CASCADE;
DROP TABLE IF EXISTS pacientes CASCADE;
DROP TABLE IF EXISTS notificacoes CASCADE;
DROP TABLE IF EXISTS leads CASCADE;
DROP TABLE IF EXISTS guias_odontologicas CASCADE;
DROP TABLE IF EXISTS gto_items CASCADE;
DROP TABLE IF EXISTS gto_builders CASCADE;
DROP TABLE IF EXISTS google_tokens CASCADE;
DROP TABLE IF EXISTS financeiro CASCADE;
DROP TABLE IF EXISTS especialidades CASCADE;
DROP TABLE IF EXISTS dentistas_horarios CASCADE;
DROP TABLE IF EXISTS agendamentos CASCADE;
DROP TABLE IF EXISTS dentistas CASCADE;
DROP TABLE IF EXISTS clinicas CASCADE;
-- 1. clinicas
CREATE TABLE clinicas (
id varchar(50) NOT NULL,
nome_fantasia varchar(255) NOT NULL,
documento varchar(50) DEFAULT NULL,
cor varchar(20) DEFAULT '#2563eb',
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 2. dentistas
CREATE TABLE dentistas (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
nome varchar(255) NOT NULL,
email varchar(255) DEFAULT NULL,
telefone varchar(20) DEFAULT NULL,
corAgenda varchar(10) DEFAULT NULL,
especialidade varchar(100) DEFAULT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
PRIMARY KEY (id)
);
-- 3. agendamentos
CREATE TABLE agendamentos (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
pacienteNome varchar(255) DEFAULT NULL,
dentistaId varchar(50) DEFAULT NULL,
start_time timestamp DEFAULT NULL,
end_time timestamp DEFAULT NULL,
status varchar(50) DEFAULT 'Pendente' CHECK (status IN ('Confirmado','Pendente','Concluido','Faltou')),
procedimento varchar(255) DEFAULT NULL,
observacoes text,
PRIMARY KEY (id),
CONSTRAINT unique_dentista_slot UNIQUE (dentistaId, start_time),
CONSTRAINT agendamentos_ibfk_1 FOREIGN KEY (dentistaId) REFERENCES dentistas (id) ON DELETE SET NULL
);
-- 4. dentistas_horarios
CREATE TABLE dentistas_horarios (
id varchar(50) NOT NULL,
dentista_id varchar(50) NOT NULL,
clinica_id varchar(50) NOT NULL,
dia_semana integer NOT NULL,
hora_inicio time NOT NULL,
hora_fim time NOT NULL,
ativo smallint DEFAULT 1,
PRIMARY KEY (id)
);
-- 5. especialidades
CREATE TABLE especialidades (
id varchar(50) NOT NULL,
nome varchar(100) NOT NULL,
descricao text,
ordem integer DEFAULT 0,
PRIMARY KEY (id)
);
-- 6. financeiro
CREATE TABLE financeiro (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
pacienteNome varchar(255) DEFAULT NULL,
descricao varchar(255) DEFAULT NULL,
valor decimal(10,2) DEFAULT NULL,
dataVencimento date DEFAULT NULL,
status varchar(50) DEFAULT NULL CHECK (status IN ('Pago','Pendente','Atrasado')),
formaPagamento varchar(50) DEFAULT NULL CHECK (formaPagamento IN ('Boleto','Pix','Cartão','Dinheiro')),
tipo varchar(50) DEFAULT 'RECEITA' CHECK (tipo IN ('RECEITA','DESPESA')),
PRIMARY KEY (id)
);
-- 7. google_tokens
CREATE TABLE google_tokens (
owner_id varchar(255) NOT NULL,
refresh_token text NOT NULL,
access_token text,
expiry_date bigint DEFAULT NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (owner_id)
);
-- 8. gto_builders
CREATE TABLE gto_builders (
id varchar(50) NOT NULL,
pacienteId varchar(50) NOT NULL,
dentistaId varchar(50) NOT NULL,
credenciadaId varchar(50) NOT NULL,
status varchar(20) DEFAULT 'RASCUNHO',
total decimal(10,2) DEFAULT '0.00',
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 9. gto_items
CREATE TABLE gto_items (
id varchar(50) NOT NULL,
builderId varchar(50) NOT NULL,
procedimentoId varchar(50) NOT NULL,
codigoTUSS varchar(50) NOT NULL,
descricao varchar(255) NOT NULL,
dente varchar(10) DEFAULT NULL,
face varchar(10) DEFAULT NULL,
quantidade integer DEFAULT 1,
valorUnitario decimal(10,2) NOT NULL,
valorTotal decimal(10,2) NOT NULL,
observacao text,
anexosCount integer DEFAULT 0,
codigoInterno varchar(50) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 10. guias_odontologicas
CREATE TABLE guias_odontologicas (
id varchar(50) NOT NULL,
numeroGuiaPrestador varchar(50) NOT NULL,
numeroGuiaOperadora varchar(50) DEFAULT NULL,
dataSolicitacao date NOT NULL,
tipoTratamento varchar(100) NOT NULL,
status varchar(50) NOT NULL,
beneficiarioId varchar(50) NOT NULL,
beneficiarioNome varchar(255) NOT NULL,
beneficiarioIdentificacao varchar(50) NOT NULL,
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 11. leads
CREATE TABLE leads (
id varchar(50) NOT NULL,
nome varchar(100) DEFAULT NULL,
sobrenome varchar(100) DEFAULT NULL,
cpf varchar(20) DEFAULT NULL,
whatsapp varchar(20) DEFAULT NULL,
tipoInteresse varchar(50) DEFAULT NULL CHECK (tipoInteresse IN ('Particular','Plano')),
dataCadastro timestamp DEFAULT NULL,
status varchar(50) DEFAULT NULL CHECK (status IN ('Novo','Convertido','Arquivado')),
PRIMARY KEY (id)
);
-- 12. notificacoes
CREATE TABLE notificacoes (
id varchar(50) NOT NULL,
titulo varchar(255) DEFAULT NULL,
mensagem text,
tipo varchar(50) DEFAULT 'sistema' CHECK (tipo IN ('lead','sistema','agenda','financeiro')),
lida smallint DEFAULT 0,
data timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 13. pacientes
CREATE TABLE pacientes (
id varchar(50) NOT NULL,
nome varchar(255) NOT NULL,
cpf varchar(20) DEFAULT NULL,
telefone varchar(20) DEFAULT NULL,
email varchar(255) DEFAULT NULL,
ultimoTratamento varchar(100) DEFAULT NULL,
convenio varchar(100) DEFAULT NULL,
dataNascimento varchar(20) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 14. planos
CREATE TABLE planos (
id varchar(50) NOT NULL,
nome varchar(100) NOT NULL,
tipo varchar(50) DEFAULT NULL CHECK (tipo IN ('Convenio','Particular','Parceria')),
descontoPadrao decimal(5,2) DEFAULT NULL,
corCartao varchar(20) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 15. procedimentos
CREATE TABLE procedimentos (
id varchar(50) NOT NULL,
nome varchar(255) NOT NULL,
descricao text,
especialidadeId varchar(50) DEFAULT NULL,
especialidadeNome varchar(100) DEFAULT NULL,
valorParticular decimal(10,2) DEFAULT NULL,
codigoInterno varchar(50) DEFAULT NULL,
categoria varchar(50) DEFAULT 'Particular',
tipo_regiao varchar(50) DEFAULT 'GERAL' CHECK (tipo_regiao IN ('DENTE','ARCO','GERAL')),
exige_face smallint DEFAULT 0,
codigo varchar(50) DEFAULT NULL,
ordem integer DEFAULT 0,
PRIMARY KEY (id),
CONSTRAINT procedimentos_ibfk_1 FOREIGN KEY (especialidadeId) REFERENCES especialidades (id) ON DELETE SET NULL
);
-- 16. procedimentos_valores_planos
CREATE TABLE procedimentos_valores_planos (
id serial NOT NULL,
procedimentoId varchar(50) DEFAULT NULL,
planoId varchar(50) DEFAULT NULL,
planoNome varchar(100) DEFAULT NULL,
valor decimal(10,2) DEFAULT NULL,
codigoPlano varchar(50) DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT procedimentos_valores_planos_ibfk_1 FOREIGN KEY (procedimentoId) REFERENCES procedimentos (id) ON DELETE CASCADE
);
-- 17. settings
CREATE TABLE settings (
id varchar(50) NOT NULL,
category varchar(50) NOT NULL,
data text,
PRIMARY KEY (id)
);
-- 18. usuarios
CREATE TABLE usuarios (
id varchar(50) NOT NULL,
nome varchar(255) DEFAULT NULL,
email varchar(100) NOT NULL,
senha varchar(255) NOT NULL,
cro varchar(20) DEFAULT NULL,
cro_uf varchar(2) DEFAULT NULL,
celular varchar(20) DEFAULT NULL,
especialidade varchar(100) DEFAULT NULL,
cor_agenda varchar(20) DEFAULT '#2563eb',
role varchar(50) DEFAULT 'admin' CHECK (role IN ('paciente','dentista','funcionario','donoclinica','admin')),
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT unique_email UNIQUE (email)
);
-- 19. vinculos
CREATE TABLE vinculos (
id varchar(50) NOT NULL,
usuario_id varchar(50) NOT NULL,
clinica_id varchar(50) NOT NULL,
role varchar(50) DEFAULT 'admin' CHECK (role IN ('paciente','dentista','funcionario','donoclinica','admin')),
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT user_clinica UNIQUE (usuario_id, clinica_id)
);
-- =============================================================================
-- STATIC DATA SEED (Corrected Encodings)
-- =============================================================================
-- clinicas
INSERT INTO clinicas VALUES ('c1','SCOREODONTO MATRIZ','00.000.000/0001-00','#059669','2026-03-10 15:55:57');
INSERT INTO clinicas VALUES ('c2','SCOREODONTO FILIAL SUL','00.000.000/0002-00','#2563eb','2026-03-10 15:55:57');
-- dentistas
INSERT INTO dentistas VALUES ('d1','c1','MURILO AMORIM','muriloamorim791@gmail.com','(67) 99999-1111','#10B981','ORTODONTIA',1,0);
INSERT INTO dentistas VALUES ('d2','c1','RUI CESAR VARGAS','ruibto@gmail.com','(67) 99999-2222','#3B82F6','IMPLANTE',1,0);
-- agendamentos
INSERT INTO agendamentos VALUES ('test_1',NULL,'WESLLEY ANTONY TELLES DA SILVA','d1','2026-02-27 12:03:07','2026-02-27 13:03:07','Pendente','TESTE',NULL);
-- especialidades
INSERT INTO especialidades VALUES ('0.29431554436274265','DENTISTICA','RESTAURAÇÕES',1);
INSERT INTO especialidades VALUES ('e1','ORTODONTIA','CORREÇÃO DA POSIÇÃO DOS DENTES E OSSOS MAXILARES',2);
INSERT INTO especialidades VALUES ('e2','IMPLANTODONTIA','IMPLANTES UNITÁRIOS E MÚLTIPLOS',4);
INSERT INTO especialidades VALUES ('e3','ENDODONTIA','TRATAMENTO DE CANAL E LESÕES PERIAPICAIS',3);
INSERT INTO especialidades VALUES ('e4','CLINICO GERAL','PROCEDIMENTOS BÁSICOS, LIMPEZA E RESTAURAÇÕES',0);
-- financeiro
INSERT INTO financeiro VALUES ('f1',NULL,'WESLLEY ANTONY','MANUTENÇÃO MENSAL ORTO',150.00,'2023-10-15','Pago','Pix','RECEITA');
INSERT INTO financeiro VALUES ('f2',NULL,'NICOLLY BEATRIZ','ENTRADA IMPLANTE',1200.00,'2023-10-20','Pendente','Boleto','RECEITA');
-- guias_odontologicas
INSERT INTO guias_odontologicas VALUES ('1','177903','O-001','2026-02-25','Tratamento Odontológico','AUTORIZADO','B1','RONEY OTTONI DE SOUZA','181442','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('2','178056','O-002','2026-02-25','Tratamento Odontológico','AUTORIZADO','B2','MILENA LIMA DIAS OTTONI DE SOUZA','157320','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('3','178179','O-003','2026-02-25','Tratamento Odontológico','AUTORIZADO','B3','EVANDRO MACHADO AZEMAN','2025100002341','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('4','177208','O-004','2026-02-24','Tratamento Odontológico','AUTORIZADO','B4','MARIA JULIA MIRANDA DA SILVA','145805','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('5','177335','O-005','2026-02-24','Tratamento Odontológico','AUTORIZADO_PARCIAL','B5','JOAO ITALO CORREA DE AMORIM SANT ANNA','443079','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('6','176468','O-006','2026-02-23','Urgência / Emergência','AUTORIZADO','B6','EDUARDO STENIO GONCALVES DOS SANTOS','435593','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('7','176572','O-007','2026-02-23','Tratamento Odontológico','AUTORIZADO','B7','LUCIA MARIA DA SILVA JULIO','127311','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('8','176666','O-008','2026-02-23','Tratamento Odontológico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('9','176721','O-009','2026-02-23','Tratamento Odontológico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38');
-- notificacoes
INSERT INTO notificacoes VALUES ('notif_fin_overdue_f2','FATURA ATRASADA','A conta "ENTRADA IMPLANTE" de R$ 1200.00 venceu em 20/10/2023','financeiro',1,'2026-02-27 10:16:03');
INSERT INTO notificacoes VALUES ('notif_fin_overdue_f2_new','FATURA ATRASADA','A conta "ENTRADA IMPLANTE" de R$ 1200.00 venceu em 20/10/2023','financeiro',0,'2026-03-10 15:55:58');
INSERT INTO notificacoes VALUES ('notif_sys_setup','CONFIGURAÇÃO DO SISTEMA','O sistema possui poucos pacientes cadastrados. Considere importar dados.','sistema',1,'2026-02-27 10:16:03');
-- pacientes
INSERT INTO pacientes VALUES ('1','WESLLEY ANTONY TELLES DA SILVA','702.013.331-20','(67) 98125-2514','WESLLEY@EMAIL.COM','ORTO','CASSEMS','17/08/2000');
INSERT INTO pacientes VALUES ('2','NICOLLY BEATRIZ TEIXEIRA','101.034.232-06','(67) 99904-7557','NICOLLY@EMAIL.COM','LIMPEZA','ODONTOSEG','22/09/2010');
INSERT INTO pacientes VALUES ('3','ROSANGELA DUTRA','917.670.830-68','(67) 9272-2487','ROSANGELA@EMAIL.COM',NULL,'CASSEMS','23/11/1978');
-- planos
INSERT INTO planos VALUES ('p1','PARTICULAR','Particular',0.00,'#1f2937');
INSERT INTO planos VALUES ('p2','UNIMED ODONTO','Convenio',0.00,'#1eb545');
INSERT INTO planos VALUES ('p3','ODONTOPREV','Convenio',0.00,'#0066cc');
INSERT INTO planos VALUES ('p4','CASSEMS','Convenio',0.00,'#e63946');
INSERT INTO planos VALUES ('p5','SESI ODONTO','Convenio',0.00,'#f4a261');
-- procedimentos
INSERT INTO procedimentos VALUES ('proc1','CONSULTA INICIAL','AVALIAÇÃO INICIAL E PLANO DE TRATAMENTO','e4','CLÍNICO GERAL',150.00,NULL,'Particular','GERAL',0,NULL,0);
INSERT INTO procedimentos VALUES ('proc2','MANUTENÇÃO ORTODÔNTICA','MANUTENÇÃO MENSAL DE APARELHO FIXO','e1','ORTODONTIA',250.00,'','Particular','ARCO',0,'',0);
-- procedimentos_valores_planos
INSERT INTO procedimentos_valores_planos (procedimentoId,planoId,planoNome,valor,codigoPlano) VALUES ('proc1','p2','UNIMED ODONTO',80.00,NULL);
INSERT INTO procedimentos_valores_planos (procedimentoId,planoId,planoNome,valor,codigoPlano) VALUES ('proc1','p3','ODONTOPREV',75.00,NULL);
-- settings
INSERT INTO settings VALUES ('main','general','{"clinicEmail":"RECEP.CONSULTTCLINIC@GMAIL.COM","clinicCalendarId":"RECEP.CONSULTTCLINIC@GMAIL.COM","googleApiKey":"AIzaSyBOCClHTZU9e38VLDljXR-O4QVtk1gCZ1k","googleCalendarIds":{"d2":"ruibto@gmail.com","d1":"muriloamorim791@gmail.com"}}');
-- usuarios
INSERT INTO usuarios VALUES ('u1','RUI CESAR','admin@scoreodonto.com','admin',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u2','DENTISTA TESTE','dentista@scoreodonto.com','123456',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u3','FUNCIONARIO TESTE','funcionario@scoreodonto.com','cassems',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u4','DONO CLINICA','dono@scoreodonto.com','Rc362514',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u5','PACIENTE TESTE','paciente@scoreodonto.com','14253636',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
-- vinculos
INSERT INTO vinculos VALUES ('v_u1_c1','u1','c1','admin','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u1_c2','u1','c2','admin','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u2_c1','u2','c1','dentista','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u3_c1','u3','c1','funcionario','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u4_c1','u4','c1','donoclinica','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u4_c2','u4','c2','donoclinica','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u5_c1','u5','c1','paciente','2026-03-10 16:27:04');
+600
View File
@@ -0,0 +1,600 @@
-- MySQL dump 10.13 Distrib 9.6.0, for Win64 (x86_64)
--
-- Host: localhost Database: sis_odonto
-- ------------------------------------------------------
-- Server version 9.6.0
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
SET @@SESSION.SQL_LOG_BIN= 0;
--
-- GTID state at the beginning of the backup
--
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'a55635f9-101c-11f1-b100-00155ded4801:1-1161';
--
-- Table structure for table `agendamentos`
--
DROP TABLE IF EXISTS `agendamentos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `agendamentos` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`clinica_id` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`pacienteNome` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`dentistaId` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`start_time` datetime DEFAULT NULL,
`end_time` datetime DEFAULT NULL,
`status` enum('Confirmado','Pendente','Concluido','Faltou') COLLATE utf8mb4_unicode_ci DEFAULT 'Pendente',
`procedimento` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`observacoes` text COLLATE utf8mb4_unicode_ci,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_dentista_slot` (`dentistaId`,`start_time`),
CONSTRAINT `agendamentos_ibfk_1` FOREIGN KEY (`dentistaId`) REFERENCES `dentistas` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `agendamentos`
--
LOCK TABLES `agendamentos` WRITE;
/*!40000 ALTER TABLE `agendamentos` DISABLE KEYS */;
INSERT INTO `agendamentos` VALUES ('test_1',NULL,'WESLLEY ANTONY TELLES DA SILVA','d1','2026-02-27 12:03:07','2026-02-27 13:03:07','Pendente','TESTE',NULL);
/*!40000 ALTER TABLE `agendamentos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `clinicas`
--
DROP TABLE IF EXISTS `clinicas`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `clinicas` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome_fantasia` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`documento` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cor` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT '#2563eb',
`createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `clinicas`
--
LOCK TABLES `clinicas` WRITE;
/*!40000 ALTER TABLE `clinicas` DISABLE KEYS */;
INSERT INTO `clinicas` VALUES ('c1','SCOREODONTO MATRIZ','00.000.000/0001-00','#059669','2026-03-10 15:55:57'),('c2','SCOREODONTO FILIAL SUL','00.000.000/0002-00','#2563eb','2026-03-10 15:55:57');
/*!40000 ALTER TABLE `clinicas` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `dentistas`
--
DROP TABLE IF EXISTS `dentistas`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `dentistas` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`clinica_id` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`telefone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`corAgenda` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`especialidade` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ativo` tinyint(1) DEFAULT '1',
`ordem` int DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `dentistas`
--
LOCK TABLES `dentistas` WRITE;
/*!40000 ALTER TABLE `dentistas` DISABLE KEYS */;
INSERT INTO `dentistas` VALUES ('d1','c1','MURILO AMORIM','muriloamorim791@gmail.com','(67) 99999-1111','#10B981','ORTODONTIA',1,0),('d2','c1','RUI CESAR VARGAS','ruibto@gmail.com','(67) 99999-2222','#3B82F6','IMPLANTE',1,0);
/*!40000 ALTER TABLE `dentistas` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `dentistas_horarios`
--
DROP TABLE IF EXISTS `dentistas_horarios`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `dentistas_horarios` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`dentista_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`clinica_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`dia_semana` int NOT NULL,
`hora_inicio` time NOT NULL,
`hora_fim` time NOT NULL,
`ativo` tinyint(1) DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `dentistas_horarios`
--
LOCK TABLES `dentistas_horarios` WRITE;
/*!40000 ALTER TABLE `dentistas_horarios` DISABLE KEYS */;
/*!40000 ALTER TABLE `dentistas_horarios` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `especialidades`
--
DROP TABLE IF EXISTS `especialidades`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `especialidades` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`descricao` text COLLATE utf8mb4_unicode_ci,
`ordem` int DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `especialidades`
--
LOCK TABLES `especialidades` WRITE;
/*!40000 ALTER TABLE `especialidades` DISABLE KEYS */;
INSERT INTO `especialidades` VALUES ('0.29431554436274265','DENTISTICA','RESTAURA├ç├òES',1),('e1','ORTODONTIA','CORRE├ç├éO DA POSI├ç├éO DOS DENTES E OSSOS MAXILARES',2),('e2','IMPLANTODONTIA','IMPLANTES UNIT├üRIOS E MULTIPLOS',4),('e3','ENDODONTIA','TRATAMENTO DE CANAL E LESÔö£├▓ES PERIAPICAIS',3),('e4','CLINICO GERAL','PROCEDIMENTOS BASICOS, LIMPEZA E RESTAURA├ç├òES',0);
/*!40000 ALTER TABLE `especialidades` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `financeiro`
--
DROP TABLE IF EXISTS `financeiro`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `financeiro` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`clinica_id` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`pacienteNome` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`descricao` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`valor` decimal(10,2) DEFAULT NULL,
`dataVencimento` date DEFAULT NULL,
`status` enum('Pago','Pendente','Atrasado') COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`formaPagamento` enum('Boleto','Pix','Cartão','Dinheiro') COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`tipo` enum('RECEITA','DESPESA') COLLATE utf8mb4_unicode_ci DEFAULT 'RECEITA',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `financeiro`
--
LOCK TABLES `financeiro` WRITE;
/*!40000 ALTER TABLE `financeiro` DISABLE KEYS */;
INSERT INTO `financeiro` VALUES ('f1',NULL,'WESLLEY ANTONY','MANUTENÇÃO MENSAL ORTO',150.00,'2023-10-15','Pago','Pix','RECEITA'),('f2',NULL,'NICOLLY BEATRIZ','ENTRADA IMPLANTE',1200.00,'2023-10-20','Pendente','Boleto','RECEITA');
/*!40000 ALTER TABLE `financeiro` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `google_tokens`
--
DROP TABLE IF EXISTS `google_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `google_tokens` (
`owner_id` varchar(255) NOT NULL,
`refresh_token` text NOT NULL,
`access_token` text,
`expiry_date` bigint DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`owner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `google_tokens`
--
LOCK TABLES `google_tokens` WRITE;
/*!40000 ALTER TABLE `google_tokens` DISABLE KEYS */;
/*!40000 ALTER TABLE `google_tokens` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `gto_builders`
--
DROP TABLE IF EXISTS `gto_builders`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `gto_builders` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`pacienteId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`dentistaId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`credenciadaId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'RASCUNHO',
`total` decimal(10,2) DEFAULT '0.00',
`createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `gto_builders`
--
LOCK TABLES `gto_builders` WRITE;
/*!40000 ALTER TABLE `gto_builders` DISABLE KEYS */;
/*!40000 ALTER TABLE `gto_builders` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `gto_items`
--
DROP TABLE IF EXISTS `gto_items`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `gto_items` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`builderId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`procedimentoId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`codigoTUSS` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`descricao` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`dente` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`face` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`quantidade` int DEFAULT '1',
`valorUnitario` decimal(10,2) NOT NULL,
`valorTotal` decimal(10,2) NOT NULL,
`observacao` text COLLATE utf8mb4_unicode_ci,
`anexosCount` int DEFAULT '0',
`codigoInterno` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `gto_items`
--
LOCK TABLES `gto_items` WRITE;
/*!40000 ALTER TABLE `gto_items` DISABLE KEYS */;
/*!40000 ALTER TABLE `gto_items` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `guias_odontologicas`
--
DROP TABLE IF EXISTS `guias_odontologicas`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `guias_odontologicas` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`numeroGuiaPrestador` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`numeroGuiaOperadora` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`dataSolicitacao` date NOT NULL,
`tipoTratamento` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`beneficiarioId` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`beneficiarioNome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`beneficiarioIdentificacao` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `guias_odontologicas`
--
LOCK TABLES `guias_odontologicas` WRITE;
/*!40000 ALTER TABLE `guias_odontologicas` DISABLE KEYS */;
INSERT INTO `guias_odontologicas` VALUES ('1','177903','O-001','2026-02-25','Tratamento Odontologico','AUTORIZADO','B1','RONEY OTTONI DE SOUZA','181442','2026-02-26 15:43:38'),('2','178056','O-002','2026-02-25','Tratamento Odontologico','AUTORIZADO','B2','MILENA LIMA DIAS OTTONI DE SOUZA','157320','2026-02-26 15:43:38'),('3','178179','O-003','2026-02-25','Tratamento Odontologico','AUTORIZADO','B3','EVANDRO MACHADO AZEMAN','2025100002341','2026-02-26 15:43:38'),('4','177208','O-004','2026-02-24','Tratamento Odontologico','AUTORIZADO','B4','MARIA JULIA MIRANDA DA SILVA','145805','2026-02-26 15:43:38'),('5','177335','O-005','2026-02-24','Tratamento Odontologico','AUTORIZADO_PARCIAL','B5','JOAO ITALO CORREA DE AMORIM SANT ANNA','443079','2026-02-26 15:43:38'),('6','176468','O-006','2026-02-23','Urgencia / Emergencia','AUTORIZADO','B6','EDUARDO STENIO GONCALVES DOS SANTOS','435593','2026-02-26 15:43:38'),('7','176572','O-007','2026-02-23','Tratamento Odontologico','AUTORIZADO','B7','LUCIA MARIA DA SILVA JULIO','127311','2026-02-26 15:43:38'),('8','176666','O-008','2026-02-23','Tratamento Odontologico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38'),('9','176721','O-009','2026-02-23','Tratamento Odontologico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38');
/*!40000 ALTER TABLE `guias_odontologicas` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leads`
--
DROP TABLE IF EXISTS `leads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leads` (
`id` varchar(50) NOT NULL,
`nome` varchar(100) DEFAULT NULL,
`sobrenome` varchar(100) DEFAULT NULL,
`cpf` varchar(20) DEFAULT NULL,
`whatsapp` varchar(20) DEFAULT NULL,
`tipoInteresse` enum('Particular','Plano') DEFAULT NULL,
`dataCadastro` datetime DEFAULT NULL,
`status` enum('Novo','Convertido','Arquivado') DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leads`
--
LOCK TABLES `leads` WRITE;
/*!40000 ALTER TABLE `leads` DISABLE KEYS */;
/*!40000 ALTER TABLE `leads` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `notificacoes`
--
DROP TABLE IF EXISTS `notificacoes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `notificacoes` (
`id` varchar(50) NOT NULL,
`titulo` varchar(255) DEFAULT NULL,
`mensagem` text,
`tipo` enum('lead','sistema','agenda','financeiro') DEFAULT 'sistema',
`lida` tinyint(1) DEFAULT '0',
`data` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `notificacoes`
--
LOCK TABLES `notificacoes` WRITE;
/*!40000 ALTER TABLE `notificacoes` DISABLE KEYS */;
INSERT INTO `notificacoes` VALUES ('notif_fin_overdue_f2','FATURA ATRASADA','A conta \"ENTRADA IMPLANTE\" de R$ 1200.00 venceu em 20/10/2023','financeiro',1,'2026-02-27 10:16:03'),('notif_fin_overdue_f2 ','FATURA ATRASADA','A conta \"ENTRADA IMPLANTE\" de R$ 1200.00 venceu em 20/10/2023 ','financeiro',0,'2026-03-10 15:55:58'),('notif_sys_setup','CONFIGURAÇÃO DO SISTEMA','O sistema possui poucos pacientes cadastrados. Considere importar dados.','sistema',1,'2026-02-27 10:16:03');
/*!40000 ALTER TABLE `notificacoes` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `pacientes`
--
DROP TABLE IF EXISTS `pacientes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `pacientes` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`cpf` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`telefone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ultimoTratamento` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`convenio` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`dataNascimento` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `pacientes`
--
LOCK TABLES `pacientes` WRITE;
/*!40000 ALTER TABLE `pacientes` DISABLE KEYS */;
INSERT INTO `pacientes` VALUES ('1','WESLLEY ANTONY TELLES DA SILVA','702.013.331-20','(67) 98125-2514','WESLLEY@EMAIL.COM','ORTO','CASSEMS','17/08/2000'),('2','NICOLLY BEATRIZ TEIXEIRA','101.034.232-06','(67) 99904-7557','NICOLLY@EMAIL.COM','LIMPEZA','ODONTOSEG','22/09/2010'),('3','ROSANGELA DUTRA','917.670.830-68','(67) 9272-2487','ROSANGELA@EMAIL.COM',NULL,'CASSEMS','23/11/1978');
/*!40000 ALTER TABLE `pacientes` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `planos`
--
DROP TABLE IF EXISTS `planos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `planos` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`tipo` enum('Convenio','Particular','Parceria') COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`descontoPadrao` decimal(5,2) DEFAULT NULL,
`corCartao` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `planos`
--
LOCK TABLES `planos` WRITE;
/*!40000 ALTER TABLE `planos` DISABLE KEYS */;
INSERT INTO `planos` VALUES ('p1','PARTICULAR','Particular',0.00,'#1f2937'),('p2','UNIMED ODONTO','Convenio',0.00,'#1eb545'),('p3','ODONTOPREV','Convenio',0.00,'#0066cc'),('p4','CASSEMS','Convenio',0.00,'#e63946'),('p5','SESI ODONTO','Convenio',0.00,'#f4a261');
/*!40000 ALTER TABLE `planos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `procedimentos`
--
DROP TABLE IF EXISTS `procedimentos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `procedimentos` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`descricao` text COLLATE utf8mb4_unicode_ci,
`especialidadeId` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`especialidadeNome` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`valorParticular` decimal(10,2) DEFAULT NULL,
`codigoInterno` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`categoria` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'Particular',
`tipo_regiao` enum('DENTE','ARCO','GERAL') COLLATE utf8mb4_unicode_ci DEFAULT 'GERAL',
`exige_face` tinyint(1) DEFAULT '0',
`codigo` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ordem` int DEFAULT '0',
PRIMARY KEY (`id`),
KEY `especialidadeId` (`especialidadeId`),
CONSTRAINT `procedimentos_ibfk_1` FOREIGN KEY (`especialidadeId`) REFERENCES `especialidades` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `procedimentos`
--
LOCK TABLES `procedimentos` WRITE;
/*!40000 ALTER TABLE `procedimentos` DISABLE KEYS */;
INSERT INTO `procedimentos` VALUES ('proc1','CONSULTA INICIAL','AVALIAÔö£├ºÔö£├óO INICIAL E PLANO DE TRATAMENTO','e4','CLÔö£├¼NICO GERAL',150.00,NULL,'Particular','GERAL',0,NULL,0),('proc2','MANUTEN├ç├âO ORTOD├öNTICA','MANUTEN├ç├éO MENSAL DE APARELHO FIXO','E1','ORTODONTIA',250.00,'','PARTICULAR','ARCO',0,'',0);
/*!40000 ALTER TABLE `procedimentos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `procedimentos_valores_planos`
--
DROP TABLE IF EXISTS `procedimentos_valores_planos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `procedimentos_valores_planos` (
`id` int NOT NULL AUTO_INCREMENT,
`procedimentoId` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`planoId` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`planoNome` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`valor` decimal(10,2) DEFAULT NULL,
`codigoPlano` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `procedimentoId` (`procedimentoId`),
KEY `planoId` (`planoId`),
CONSTRAINT `procedimentos_valores_planos_ibfk_1` FOREIGN KEY (`procedimentoId`) REFERENCES `procedimentos` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `procedimentos_valores_planos`
--
LOCK TABLES `procedimentos_valores_planos` WRITE;
/*!40000 ALTER TABLE `procedimentos_valores_planos` DISABLE KEYS */;
INSERT INTO `procedimentos_valores_planos` VALUES (1,'proc1','p2','UNIMED ODONTO',80.00,NULL),(2,'proc1','p3','ODONTOPREV',75.00,NULL);
/*!40000 ALTER TABLE `procedimentos_valores_planos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `settings`
--
DROP TABLE IF EXISTS `settings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `settings` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`category` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`data` text COLLATE utf8mb4_unicode_ci,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `settings`
--
LOCK TABLES `settings` WRITE;
/*!40000 ALTER TABLE `settings` DISABLE KEYS */;
INSERT INTO `settings` VALUES ('main','general','{\"clinicEmail\":\"RECEP.CONSULTTCLINIC@GMAIL.COM\",\"clinicCalendarId\":\"RECEP.CONSULTTCLINIC@GMAIL.COM\",\"googleApiKey\":\"AIzaSyBOCClHTZU9e38VLDljXR-O4QVtk1gCZ1k\",\"googleCalendarIds\":{\"d2\":\"ruibto@gmail.com\",\"d1\":\"muriloamorim791@gmail.com\"}}');
/*!40000 ALTER TABLE `settings` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `usuarios`
--
DROP TABLE IF EXISTS `usuarios`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `usuarios` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`nome` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`email` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`senha` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`cro` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cro_uf` varchar(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`celular` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`especialidade` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cor_agenda` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT '#2563eb',
`role` enum('paciente','dentista','funcionario','donoclinica','admin') COLLATE utf8mb4_unicode_ci DEFAULT 'admin',
`createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `usuarios`
--
LOCK TABLES `usuarios` WRITE;
/*!40000 ALTER TABLE `usuarios` DISABLE KEYS */;
INSERT INTO `usuarios` VALUES ('u1','RUI CESAR','admin@scoreodonto.com','admin',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04'),('u2','DENTISTA TESTE','dentista@scoreodonto.com','123456',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04'),('u3','FUNCIONARIO TESTE','funcionario@scoreodonto.com','cassems',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04'),('u4','DONO CLINICA','dono@scoreodonto.com','Rc362514',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04'),('u5','PACIENTE TESTE','paciente@scoreodonto.com','14253636',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
/*!40000 ALTER TABLE `usuarios` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `vinculos`
--
DROP TABLE IF EXISTS `vinculos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `vinculos` (
`id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`usuario_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`clinica_id` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`role` enum('paciente','dentista','funcionario','donoclinica','admin') COLLATE utf8mb4_unicode_ci DEFAULT 'admin',
`createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `user_clinica` (`usuario_id`,`clinica_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `vinculos`
--
LOCK TABLES `vinculos` WRITE;
/*!40000 ALTER TABLE `vinculos` DISABLE KEYS */;
INSERT INTO `vinculos` VALUES ('v_u1_c1','u1','c1','admin','2026-03-10 16:27:04'),('v_u1_c2','u1','c2','admin','2026-03-10 16:27:04'),('v_u2_c1','u2','c1','dentista','2026-03-10 16:27:04'),('v_u3_c1','u3','c1','funcionario','2026-03-10 16:27:04'),('v_u4_c1','u4','c1','donoclinica','2026-03-10 16:27:04'),('v_u4_c2','u4','c2','donoclinica','2026-03-10 16:27:04'),('v_u5_c1','u5','c1','paciente','2026-03-10 16:27:04');
/*!40000 ALTER TABLE `vinculos` ENABLE KEYS */;
UNLOCK TABLES;
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-03-15 20:33:25
+87
View File
@@ -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.*
+80
View File
@@ -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.
+63
View File
@@ -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.
+49
View File
@@ -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();
+217
View File
@@ -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));
}
})();
Binary file not shown.
+51
View File
@@ -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();
+69
View File
@@ -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();
+40
View File
@@ -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);
+27
View File
@@ -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
+27
View File
@@ -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

Binary file not shown.
Binary file not shown.
+97
View File
@@ -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;
+207
View File
@@ -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>
);
};
+146
View File
@@ -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>
);
};
+87
View File
@@ -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>
);
};
+106
View File
@@ -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>
);
};
+21
View File
@@ -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%;
}
+15
View File
@@ -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>
+14
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+154
View File
@@ -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);
}
}
+65
View File
@@ -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);
}
}
Binary file not shown.
+28
View File
@@ -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"
]
}
+91
View File
@@ -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;
}
+13
View File
@@ -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,
}
});
+595
View File
@@ -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"
}
}
}
}
+20
View File
@@ -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"
}
}
+91
View File
@@ -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();
}
})();
+52
View File
@@ -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();
}
})();
+45
View File
@@ -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();
}
})();
+138
View File
@@ -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();
}
})();
Binary file not shown.
+9
View File
@@ -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
+24
View File
@@ -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?
+22
View File
@@ -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>
+319
View File
@@ -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;
+20
View File
@@ -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`
+42
View File
@@ -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();
+25
View File
@@ -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();
+12
View File
@@ -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();
+17
View File
@@ -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>
);
};
+80
View File
@@ -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 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>
);
};
+152
View File
@@ -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>
);
};
+38
View File
@@ -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'),
};
};
@@ -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 };
}
+82
View File
@@ -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;
}
+38
View File
@@ -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>
+43
View File
@@ -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.';
}
}
+207
View File
@@ -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>
);
};
+124
View File
@@ -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>
);
+87
View File
@@ -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>
);
};
+106
View File
@@ -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>
);
};
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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"
}
}
+143
View File
@@ -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)
};
+68
View File
@@ -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
+809
View File
@@ -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: '1824 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: '2430 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 [];
}
}
};
+135
View File
@@ -0,0 +1,135 @@
-- Create Database
CREATE DATABASE IF NOT EXISTS sis_odonto;
USE sis_odonto;
-- Tables
CREATE TABLE IF NOT EXISTS pacientes (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
cpf VARCHAR(20),
telefone VARCHAR(20),
email VARCHAR(255),
ultimoTratamento VARCHAR(100),
convenio VARCHAR(100),
dataNascimento VARCHAR(20)
);
CREATE TABLE IF NOT EXISTS dentistas (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
email VARCHAR(255),
telefone VARCHAR(20),
corAgenda VARCHAR(10),
especialidade VARCHAR(100),
ativo BOOLEAN DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS especialidades (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
descricao TEXT
);
CREATE TABLE IF NOT EXISTS planos (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
tipo ENUM('Convenio', 'Particular', 'Parceria') NOT NULL,
descontoPadrao INT DEFAULT 0,
corCartao VARCHAR(10)
);
CREATE TABLE IF NOT EXISTS procedimentos (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
descricao TEXT,
especialidadeId VARCHAR(50),
especialidadeNome VARCHAR(100),
valorParticular DECIMAL(10, 2),
FOREIGN KEY (especialidadeId) REFERENCES especialidades(id)
);
CREATE TABLE IF NOT EXISTS procedimentos_valores_planos (
id INT AUTO_INCREMENT PRIMARY KEY,
procedimentoId VARCHAR(50),
planoId VARCHAR(50),
planoNome VARCHAR(100),
valor DECIMAL(10, 2),
FOREIGN KEY (procedimentoId) REFERENCES procedimentos(id),
FOREIGN KEY (planoId) REFERENCES planos(id)
);
CREATE TABLE IF NOT EXISTS agendamentos (
id VARCHAR(50) PRIMARY KEY,
pacienteNome VARCHAR(255),
dentistaId VARCHAR(50),
start_time DATETIME,
end_time DATETIME,
status ENUM('Confirmado', 'Pendente', 'Concluido'),
procedimento VARCHAR(255),
observacoes TEXT,
FOREIGN KEY (dentistaId) REFERENCES dentistas(id)
);
CREATE TABLE IF NOT EXISTS financeiro (
id VARCHAR(50) PRIMARY KEY,
pacienteNome VARCHAR(255),
descricao VARCHAR(255),
valor DECIMAL(10, 2),
dataVencimento DATE,
status ENUM('Pago', 'Pendente', 'Atrasado'),
formaPagamento ENUM('Boleto', 'Pix', 'Cartão', 'Dinheiro')
);
CREATE TABLE IF NOT EXISTS leads (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100),
sobrenome VARCHAR(100),
cpf VARCHAR(20),
whatsapp VARCHAR(20),
tipoInteresse ENUM('Particular', 'Plano'),
dataCadastro DATETIME,
status ENUM('Novo', 'Convertido', 'Arquivado')
);
CREATE TABLE IF NOT EXISTS notificacoes (
id VARCHAR(50) PRIMARY KEY,
titulo VARCHAR(255),
mensagem TEXT,
tipo ENUM('lead', 'sistema', 'agenda'),
lida BOOLEAN DEFAULT FALSE,
data_notif DATETIME
);
-- Seed Initial Data
INSERT IGNORE INTO especialidades (id, nome, descricao) VALUES
('e1', 'ORTODONTIA', 'CORREÇÃO DA POSIÇÃO DOS DENTES E OSSOS MAXILARES'),
('e2', 'IMPLANTODONTIA', 'IMPLANTES DENTÁRIOS E REABILITAÇÃO ORAL'),
('e3', 'ENDODONTIA', 'TRATAMENTO DE CANAL E LESÕES PERIAPICAIS'),
('e4', 'CLÍNICO GERAL', 'PROCEDIMENTOS BÁSICOS, LIMPEZA E RESTAURAÇÕES');
INSERT IGNORE INTO dentistas (id, nome, email, telefone, corAgenda, especialidade, ativo) VALUES
('d1', 'MURILO AMORIM', 'muriloamorim791@gmail.com', '(67) 99999-1111', '#10B981', 'ORTODONTIA', TRUE),
('d2', 'RUI CESAR VARGAS', 'ruibto@gmail.com', '(67) 99999-2222', '#3B82F6', 'IMPLANTE', TRUE);
INSERT IGNORE INTO planos (id, nome, tipo, descontoPadrao, corCartao) VALUES
('p1', 'PARTICULAR', 'Particular', 0, '#1f2937'),
('p2', 'UNIMED ODONTO', 'Convenio', 0, '#059669'),
('p3', 'ODONTOPREV', 'Convenio', 0, '#dc2626'),
('p4', 'PARCERIA SINDICATO', 'Parceria', 15, '#2563eb');
INSERT IGNORE INTO pacientes (id, nome, cpf, telefone, email, ultimoTratamento, convenio, dataNascimento) VALUES
('1', 'WESLLEY ANTONY TELLES DA SILVA', '702.013.331-20', '(67) 98125-2514', 'WESLLEY@EMAIL.COM', 'ORTO', 'CASSEMS', '17/08/2000'),
('2', 'NICOLLY BEATRIZ TEIXEIRA', '101.034.232-06', '(67) 99904-7557', 'NICOLLY@EMAIL.COM', 'LIMPEZA', 'ODONTOSEG', '22/09/2010'),
('3', 'ROSANGELA DUTRA', '917.670.830-68', '(67) 9272-2487', 'ROSANGELA@EMAIL.COM', NULL, 'CASSEMS', '23/11/1978');
INSERT IGNORE INTO procedimentos (id, nome, descricao, especialidadeId, especialidadeNome, valorParticular) VALUES
('proc1', 'CONSULTA INICIAL', 'AVALIAÇÃO INICIAL E PLANO DE TRATAMENTO', 'e4', 'CLÍNICO GERAL', 150.00),
('proc2', 'MANUTENÇÃO ORTODÔNTICA', 'MANUTENÇÃO MENSAL DE APARELHO FIXO', 'e1', 'ORTODONTIA', 250.00);
INSERT IGNORE INTO procedimentos_valores_planos (procedimentoId, planoId, planoNome, valor) VALUES
('proc1', 'p2', 'UNIMED ODONTO', 80.00),
('proc1', 'p3', 'ODONTOPREV', 75.00);
INSERT IGNORE INTO financeiro (id, pacienteNome, descricao, valor, dataVencimento, status, formaPagamento) VALUES
('f1', 'WESLLEY ANTONY', 'MANUTENÇÃO MENSAL ORTO', 150.00, '2023-10-15', 'Pago', 'Pix'),
('f2', 'NICOLLY BEATRIZ', 'ENTRADA IMPLANTE', 1200.00, '2023-10-20', 'Pendente', 'Boleto');
+52
View File
@@ -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
Binary file not shown.
@@ -0,0 +1,349 @@
-- =============================================================================
-- PostgreSQL Database Initialization Script for ScoreOdonto CRM
-- Conversion from MySQL with corrected encodings and standard constraints
-- =============================================================================
-- Clean up existing tables (with cascade for safety)
DROP TABLE IF EXISTS vinculos CASCADE;
DROP TABLE IF EXISTS usuarios CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS procedimentos_valores_planos CASCADE;
DROP TABLE IF EXISTS procedimentos CASCADE;
DROP TABLE IF EXISTS planos CASCADE;
DROP TABLE IF EXISTS pacientes CASCADE;
DROP TABLE IF EXISTS notificacoes CASCADE;
DROP TABLE IF EXISTS leads CASCADE;
DROP TABLE IF EXISTS guias_odontologicas CASCADE;
DROP TABLE IF EXISTS gto_items CASCADE;
DROP TABLE IF EXISTS gto_builders CASCADE;
DROP TABLE IF EXISTS google_tokens CASCADE;
DROP TABLE IF EXISTS financeiro CASCADE;
DROP TABLE IF EXISTS especialidades CASCADE;
DROP TABLE IF EXISTS dentistas_horarios CASCADE;
DROP TABLE IF EXISTS agendamentos CASCADE;
DROP TABLE IF EXISTS dentistas CASCADE;
DROP TABLE IF EXISTS clinicas CASCADE;
-- 1. clinicas
CREATE TABLE clinicas (
id varchar(50) NOT NULL,
nome_fantasia varchar(255) NOT NULL,
documento varchar(50) DEFAULT NULL,
cor varchar(20) DEFAULT '#2563eb',
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 2. dentistas
CREATE TABLE dentistas (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
nome varchar(255) NOT NULL,
email varchar(255) DEFAULT NULL,
telefone varchar(20) DEFAULT NULL,
corAgenda varchar(10) DEFAULT NULL,
especialidade varchar(100) DEFAULT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
PRIMARY KEY (id)
);
-- 3. agendamentos
CREATE TABLE agendamentos (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
pacienteNome varchar(255) DEFAULT NULL,
dentistaId varchar(50) DEFAULT NULL,
start_time timestamp DEFAULT NULL,
end_time timestamp DEFAULT NULL,
status varchar(50) DEFAULT 'Pendente' CHECK (status IN ('Confirmado','Pendente','Concluido','Faltou')),
procedimento varchar(255) DEFAULT NULL,
observacoes text,
PRIMARY KEY (id),
CONSTRAINT unique_dentista_slot UNIQUE (dentistaId, start_time),
CONSTRAINT agendamentos_ibfk_1 FOREIGN KEY (dentistaId) REFERENCES dentistas (id) ON DELETE SET NULL
);
-- 4. dentistas_horarios
CREATE TABLE dentistas_horarios (
id varchar(50) NOT NULL,
dentista_id varchar(50) NOT NULL,
clinica_id varchar(50) NOT NULL,
dia_semana integer NOT NULL,
hora_inicio time NOT NULL,
hora_fim time NOT NULL,
ativo smallint DEFAULT 1,
PRIMARY KEY (id)
);
-- 5. especialidades
CREATE TABLE especialidades (
id varchar(50) NOT NULL,
nome varchar(100) NOT NULL,
descricao text,
ordem integer DEFAULT 0,
PRIMARY KEY (id)
);
-- 6. financeiro
CREATE TABLE financeiro (
id varchar(50) NOT NULL,
clinica_id varchar(50) DEFAULT NULL,
pacienteNome varchar(255) DEFAULT NULL,
descricao varchar(255) DEFAULT NULL,
valor decimal(10,2) DEFAULT NULL,
dataVencimento date DEFAULT NULL,
status varchar(50) DEFAULT NULL CHECK (status IN ('Pago','Pendente','Atrasado')),
formaPagamento varchar(50) DEFAULT NULL CHECK (formaPagamento IN ('Boleto','Pix','Cartão','Dinheiro')),
tipo varchar(50) DEFAULT 'RECEITA' CHECK (tipo IN ('RECEITA','DESPESA')),
PRIMARY KEY (id)
);
-- 7. google_tokens
CREATE TABLE google_tokens (
owner_id varchar(255) NOT NULL,
refresh_token text NOT NULL,
access_token text,
expiry_date bigint DEFAULT NULL,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (owner_id)
);
-- 8. gto_builders
CREATE TABLE gto_builders (
id varchar(50) NOT NULL,
pacienteId varchar(50) NOT NULL,
dentistaId varchar(50) NOT NULL,
credenciadaId varchar(50) NOT NULL,
status varchar(20) DEFAULT 'RASCUNHO',
total decimal(10,2) DEFAULT '0.00',
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 9. gto_items
CREATE TABLE gto_items (
id varchar(50) NOT NULL,
builderId varchar(50) NOT NULL,
procedimentoId varchar(50) NOT NULL,
codigoTUSS varchar(50) NOT NULL,
descricao varchar(255) NOT NULL,
dente varchar(10) DEFAULT NULL,
face varchar(10) DEFAULT NULL,
quantidade integer DEFAULT 1,
valorUnitario decimal(10,2) NOT NULL,
valorTotal decimal(10,2) NOT NULL,
observacao text,
anexosCount integer DEFAULT 0,
codigoInterno varchar(50) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 10. guias_odontologicas
CREATE TABLE guias_odontologicas (
id varchar(50) NOT NULL,
numeroGuiaPrestador varchar(50) NOT NULL,
numeroGuiaOperadora varchar(50) DEFAULT NULL,
dataSolicitacao date NOT NULL,
tipoTratamento varchar(100) NOT NULL,
status varchar(50) NOT NULL,
beneficiarioId varchar(50) NOT NULL,
beneficiarioNome varchar(255) NOT NULL,
beneficiarioIdentificacao varchar(50) NOT NULL,
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 11. leads
CREATE TABLE leads (
id varchar(50) NOT NULL,
nome varchar(100) DEFAULT NULL,
sobrenome varchar(100) DEFAULT NULL,
cpf varchar(20) DEFAULT NULL,
whatsapp varchar(20) DEFAULT NULL,
tipoInteresse varchar(50) DEFAULT NULL CHECK (tipoInteresse IN ('Particular','Plano')),
dataCadastro timestamp DEFAULT NULL,
status varchar(50) DEFAULT NULL CHECK (status IN ('Novo','Convertido','Arquivado')),
PRIMARY KEY (id)
);
-- 12. notificacoes
CREATE TABLE notificacoes (
id varchar(50) NOT NULL,
titulo varchar(255) DEFAULT NULL,
mensagem text,
tipo varchar(50) DEFAULT 'sistema' CHECK (tipo IN ('lead','sistema','agenda','financeiro')),
lida smallint DEFAULT 0,
data timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- 13. pacientes
CREATE TABLE pacientes (
id varchar(50) NOT NULL,
nome varchar(255) NOT NULL,
cpf varchar(20) DEFAULT NULL,
telefone varchar(20) DEFAULT NULL,
email varchar(255) DEFAULT NULL,
ultimoTratamento varchar(100) DEFAULT NULL,
convenio varchar(100) DEFAULT NULL,
dataNascimento varchar(20) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 14. planos
CREATE TABLE planos (
id varchar(50) NOT NULL,
nome varchar(100) NOT NULL,
tipo varchar(50) DEFAULT NULL CHECK (tipo IN ('Convenio','Particular','Parceria')),
descontoPadrao decimal(5,2) DEFAULT NULL,
corCartao varchar(20) DEFAULT NULL,
PRIMARY KEY (id)
);
-- 15. procedimentos
CREATE TABLE procedimentos (
id varchar(50) NOT NULL,
nome varchar(255) NOT NULL,
descricao text,
especialidadeId varchar(50) DEFAULT NULL,
especialidadeNome varchar(100) DEFAULT NULL,
valorParticular decimal(10,2) DEFAULT NULL,
codigoInterno varchar(50) DEFAULT NULL,
categoria varchar(50) DEFAULT 'Particular',
tipo_regiao varchar(50) DEFAULT 'GERAL' CHECK (tipo_regiao IN ('DENTE','ARCO','GERAL')),
exige_face smallint DEFAULT 0,
codigo varchar(50) DEFAULT NULL,
ordem integer DEFAULT 0,
PRIMARY KEY (id),
CONSTRAINT procedimentos_ibfk_1 FOREIGN KEY (especialidadeId) REFERENCES especialidades (id) ON DELETE SET NULL
);
-- 16. procedimentos_valores_planos
CREATE TABLE procedimentos_valores_planos (
id serial NOT NULL,
procedimentoId varchar(50) DEFAULT NULL,
planoId varchar(50) DEFAULT NULL,
planoNome varchar(100) DEFAULT NULL,
valor decimal(10,2) DEFAULT NULL,
codigoPlano varchar(50) DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT procedimentos_valores_planos_ibfk_1 FOREIGN KEY (procedimentoId) REFERENCES procedimentos (id) ON DELETE CASCADE
);
-- 17. settings
CREATE TABLE settings (
id varchar(50) NOT NULL,
category varchar(50) NOT NULL,
data text,
PRIMARY KEY (id)
);
-- 18. usuarios
CREATE TABLE usuarios (
id varchar(50) NOT NULL,
nome varchar(255) DEFAULT NULL,
email varchar(100) NOT NULL,
senha varchar(255) NOT NULL,
cro varchar(20) DEFAULT NULL,
cro_uf varchar(2) DEFAULT NULL,
celular varchar(20) DEFAULT NULL,
especialidade varchar(100) DEFAULT NULL,
cor_agenda varchar(20) DEFAULT '#2563eb',
role varchar(50) DEFAULT 'admin' CHECK (role IN ('paciente','dentista','funcionario','donoclinica','admin')),
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT unique_email UNIQUE (email)
);
-- 19. vinculos
CREATE TABLE vinculos (
id varchar(50) NOT NULL,
usuario_id varchar(50) NOT NULL,
clinica_id varchar(50) NOT NULL,
role varchar(50) DEFAULT 'admin' CHECK (role IN ('paciente','dentista','funcionario','donoclinica','admin')),
createdAt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT user_clinica UNIQUE (usuario_id, clinica_id)
);
-- =============================================================================
-- STATIC DATA SEED (Corrected Encodings)
-- =============================================================================
-- clinicas
INSERT INTO clinicas VALUES ('c1','SCOREODONTO MATRIZ','00.000.000/0001-00','#059669','2026-03-10 15:55:57');
INSERT INTO clinicas VALUES ('c2','SCOREODONTO FILIAL SUL','00.000.000/0002-00','#2563eb','2026-03-10 15:55:57');
-- dentistas
INSERT INTO dentistas VALUES ('d1','c1','MURILO AMORIM','muriloamorim791@gmail.com','(67) 99999-1111','#10B981','ORTODONTIA',1,0);
INSERT INTO dentistas VALUES ('d2','c1','RUI CESAR VARGAS','ruibto@gmail.com','(67) 99999-2222','#3B82F6','IMPLANTE',1,0);
-- agendamentos
INSERT INTO agendamentos VALUES ('test_1',NULL,'WESLLEY ANTONY TELLES DA SILVA','d1','2026-02-27 12:03:07','2026-02-27 13:03:07','Pendente','TESTE',NULL);
-- especialidades
INSERT INTO especialidades VALUES ('0.29431554436274265','DENTISTICA','RESTAURAÇÕES',1);
INSERT INTO especialidades VALUES ('e1','ORTODONTIA','CORREÇÃO DA POSIÇÃO DOS DENTES E OSSOS MAXILARES',2);
INSERT INTO especialidades VALUES ('e2','IMPLANTODONTIA','IMPLANTES UNITÁRIOS E MÚLTIPLOS',4);
INSERT INTO especialidades VALUES ('e3','ENDODONTIA','TRATAMENTO DE CANAL E LESÕES PERIAPICAIS',3);
INSERT INTO especialidades VALUES ('e4','CLINICO GERAL','PROCEDIMENTOS BÁSICOS, LIMPEZA E RESTAURAÇÕES',0);
-- financeiro
INSERT INTO financeiro VALUES ('f1',NULL,'WESLLEY ANTONY','MANUTENÇÃO MENSAL ORTO',150.00,'2023-10-15','Pago','Pix','RECEITA');
INSERT INTO financeiro VALUES ('f2',NULL,'NICOLLY BEATRIZ','ENTRADA IMPLANTE',1200.00,'2023-10-20','Pendente','Boleto','RECEITA');
-- guias_odontologicas
INSERT INTO guias_odontologicas VALUES ('1','177903','O-001','2026-02-25','Tratamento Odontológico','AUTORIZADO','B1','RONEY OTTONI DE SOUZA','181442','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('2','178056','O-002','2026-02-25','Tratamento Odontológico','AUTORIZADO','B2','MILENA LIMA DIAS OTTONI DE SOUZA','157320','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('3','178179','O-003','2026-02-25','Tratamento Odontológico','AUTORIZADO','B3','EVANDRO MACHADO AZEMAN','2025100002341','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('4','177208','O-004','2026-02-24','Tratamento Odontológico','AUTORIZADO','B4','MARIA JULIA MIRANDA DA SILVA','145805','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('5','177335','O-005','2026-02-24','Tratamento Odontológico','AUTORIZADO_PARCIAL','B5','JOAO ITALO CORREA DE AMORIM SANT ANNA','443079','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('6','176468','O-006','2026-02-23','Urgência / Emergência','AUTORIZADO','B6','EDUARDO STENIO GONCALVES DOS SANTOS','435593','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('7','176572','O-007','2026-02-23','Tratamento Odontológico','AUTORIZADO','B7','LUCIA MARIA DA SILVA JULIO','127311','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('8','176666','O-008','2026-02-23','Tratamento Odontológico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38');
INSERT INTO guias_odontologicas VALUES ('9','176721','O-009','2026-02-23','Tratamento Odontológico','AUTORIZADO','B8','JAIRO HIDEKI NAGAO','96512','2026-02-26 15:43:38');
-- notificacoes
INSERT INTO notificacoes VALUES ('notif_fin_overdue_f2','FATURA ATRASADA','A conta "ENTRADA IMPLANTE" de R$ 1200.00 venceu em 20/10/2023','financeiro',1,'2026-02-27 10:16:03');
INSERT INTO notificacoes VALUES ('notif_fin_overdue_f2_new','FATURA ATRASADA','A conta "ENTRADA IMPLANTE" de R$ 1200.00 venceu em 20/10/2023','financeiro',0,'2026-03-10 15:55:58');
INSERT INTO notificacoes VALUES ('notif_sys_setup','CONFIGURAÇÃO DO SISTEMA','O sistema possui poucos pacientes cadastrados. Considere importar dados.','sistema',1,'2026-02-27 10:16:03');
-- pacientes
INSERT INTO pacientes VALUES ('1','WESLLEY ANTONY TELLES DA SILVA','702.013.331-20','(67) 98125-2514','WESLLEY@EMAIL.COM','ORTO','CASSEMS','17/08/2000');
INSERT INTO pacientes VALUES ('2','NICOLLY BEATRIZ TEIXEIRA','101.034.232-06','(67) 99904-7557','NICOLLY@EMAIL.COM','LIMPEZA','ODONTOSEG','22/09/2010');
INSERT INTO pacientes VALUES ('3','ROSANGELA DUTRA','917.670.830-68','(67) 9272-2487','ROSANGELA@EMAIL.COM',NULL,'CASSEMS','23/11/1978');
-- planos
INSERT INTO planos VALUES ('p1','PARTICULAR','Particular',0.00,'#1f2937');
INSERT INTO planos VALUES ('p2','UNIMED ODONTO','Convenio',0.00,'#1eb545');
INSERT INTO planos VALUES ('p3','ODONTOPREV','Convenio',0.00,'#0066cc');
INSERT INTO planos VALUES ('p4','CASSEMS','Convenio',0.00,'#e63946');
INSERT INTO planos VALUES ('p5','SESI ODONTO','Convenio',0.00,'#f4a261');
-- procedimentos
INSERT INTO procedimentos VALUES ('proc1','CONSULTA INICIAL','AVALIAÇÃO INICIAL E PLANO DE TRATAMENTO','e4','CLÍNICO GERAL',150.00,NULL,'Particular','GERAL',0,NULL,0);
INSERT INTO procedimentos VALUES ('proc2','MANUTENÇÃO ORTODÔNTICA','MANUTENÇÃO MENSAL DE APARELHO FIXO','e1','ORTODONTIA',250.00,'','Particular','ARCO',0,'',0);
-- procedimentos_valores_planos
INSERT INTO procedimentos_valores_planos (procedimentoId,planoId,planoNome,valor,codigoPlano) VALUES ('proc1','p2','UNIMED ODONTO',80.00,NULL);
INSERT INTO procedimentos_valores_planos (procedimentoId,planoId,planoNome,valor,codigoPlano) VALUES ('proc1','p3','ODONTOPREV',75.00,NULL);
-- settings
INSERT INTO settings VALUES ('main','general','{"clinicEmail":"RECEP.CONSULTTCLINIC@GMAIL.COM","clinicCalendarId":"RECEP.CONSULTTCLINIC@GMAIL.COM","googleApiKey":"AIzaSyBOCClHTZU9e38VLDljXR-O4QVtk1gCZ1k","googleCalendarIds":{"d2":"ruibto@gmail.com","d1":"muriloamorim791@gmail.com"}}');
-- usuarios
INSERT INTO usuarios VALUES ('u1','RUI CESAR','admin@scoreodonto.com','admin',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u2','DENTISTA TESTE','dentista@scoreodonto.com','123456',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u3','FUNCIONARIO TESTE','funcionario@scoreodonto.com','cassems',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u4','DONO CLINICA','dono@scoreodonto.com','Rc362514',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
INSERT INTO usuarios VALUES ('u5','PACIENTE TESTE','paciente@scoreodonto.com','14253636',NULL,NULL,NULL,NULL,'#2563eb','admin','2026-03-10 16:27:04');
-- vinculos
INSERT INTO vinculos VALUES ('v_u1_c1','u1','c1','admin','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u1_c2','u1','c2','admin','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u2_c1','u2','c1','dentista','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u3_c1','u3','c1','funcionario','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u4_c1','u4','c1','donoclinica','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u4_c2','u4','c2','donoclinica','2026-03-10 16:27:04');
INSERT INTO vinculos VALUES ('v_u5_c1','u5','c1','paciente','2026-03-10 16:27:04');
+31
View File
@@ -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();
+29
View File
@@ -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();
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+216
View File
@@ -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
}
+909
View File
@@ -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 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>
);
};
+573
View File
@@ -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>
);
};
+205
View File
@@ -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>
);
};
+284
View File
@@ -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ê 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>
);
};
+378
View File
@@ -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>
);
};
+532
View File
@@ -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 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>
);
};
+291
View File
@@ -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 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 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>
);
};
+308
View File
@@ -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>
);
};
+107
View File
@@ -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>
);
};

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