feat: migrate ScoreOdonto CRM to Docker, PostgreSQL, and DragonflyDB
This commit is contained in:
+5
-21
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import pg from 'pg';
|
||||
const { Pool } = pg;
|
||||
|
||||
// Simple query translator from MySQL dialect to PostgreSQL
|
||||
function translateQuery(query, params) {
|
||||
if (typeof query !== 'string') return { sql: query, pgParams: params };
|
||||
|
||||
let sql = query;
|
||||
let pgParams = params ? [...params] : [];
|
||||
|
||||
// Remove or convert backticks
|
||||
sql = sql.replace(/`([^`]+)`/g, '"$1"');
|
||||
|
||||
// INSERT INTO table SET ?
|
||||
if (sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)) {
|
||||
const table = sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)[1].replace(/"/g, '');
|
||||
const dataObj = pgParams[0];
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
const cols = keys.map(k => `"${k}"`).join(', ');
|
||||
const valuesPlaceholder = keys.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
sql = `INSERT INTO "${table}" (${cols}) VALUES (${valuesPlaceholder})`;
|
||||
pgParams = keys.map(k => dataObj[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE table SET ? WHERE ...
|
||||
if (sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i)) {
|
||||
const match = sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i);
|
||||
const table = match[1].replace(/"/g, '');
|
||||
const whereClause = match[2];
|
||||
const dataObj = pgParams[0];
|
||||
const extraParams = pgParams.slice(1);
|
||||
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
let paramIdx = 1;
|
||||
const setClause = keys.map(k => `"${k}" = $${paramIdx++}`).join(', ');
|
||||
|
||||
let postgresWhere = whereClause;
|
||||
let tempIdx = paramIdx;
|
||||
postgresWhere = postgresWhere.replace(/\?/g, () => `$${tempIdx++}`);
|
||||
|
||||
sql = `UPDATE "${table}" SET ${setClause} WHERE ${postgresWhere}`;
|
||||
pgParams = [...keys.map(k => dataObj[k]), ...extraParams];
|
||||
}
|
||||
}
|
||||
|
||||
// Translate standard '?' to '$1', '$2', etc.
|
||||
let count = 1;
|
||||
sql = sql.replace(/\?/g, () => `$${count++}`);
|
||||
|
||||
// ON DUPLICATE KEY UPDATE -> ON CONFLICT
|
||||
if (sql.match(/ON\s+DUPLICATE\s+KEY\s+UPDATE/i)) {
|
||||
if (sql.toLowerCase().includes('settings')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*VALUES\(data\)|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*\$4|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*EXCLUDED\.data/i, 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data');
|
||||
} else if (sql.toLowerCase().includes('google_tokens')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (owner_id) DO UPDATE SET refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date');
|
||||
} else if (sql.toLowerCase().includes('usuarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf, celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('vinculos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = EXCLUDED.role');
|
||||
} else if (sql.toLowerCase().includes('dentistas_horarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio, hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo');
|
||||
} else if (sql.toLowerCase().includes('dentistas')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('procedimentos_valores_planos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor');
|
||||
} else if (sql.toLowerCase().includes('procedimentos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, "especialidadeId" = EXCLUDED."especialidadeId", "especialidadeNome" = EXCLUDED."especialidadeNome", "valorParticular" = EXCLUDED."valorParticular", "codigoInterno" = EXCLUDED."codigoInterno", categoria = EXCLUDED.categoria, tipo_regiao = EXCLUDED.tipo_regiao, exige_face = EXCLUDED.exige_face, codigo = EXCLUDED.codigo');
|
||||
}
|
||||
}
|
||||
|
||||
// DATE_FORMAT -> TO_CHAR
|
||||
sql = sql.replace(/DATE_FORMAT\(([^,]+),\s*['"]%Y-%m['"]\)/gi, "TO_CHAR($1, 'YYYY-MM')");
|
||||
// DATE_SUB -> CURRENT_DATE - INTERVAL
|
||||
sql = sql.replace(/DATE_SUB\(CURDATE\(\),\s*INTERVAL\s+(\d+)\s+MONTH\)/gi, "CURRENT_DATE - INTERVAL '$1 MONTH'");
|
||||
|
||||
// SET FOREIGN_KEY_CHECKS
|
||||
if (sql.match(/SET\s+FOREIGN_KEY_CHECKS\s*=\s*\d+/i)) {
|
||||
sql = "SELECT 1";
|
||||
}
|
||||
|
||||
return { sql, pgParams };
|
||||
}
|
||||
|
||||
class PostgresConnection {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.client.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async beginTransaction() {
|
||||
await this.client.query('BEGIN');
|
||||
}
|
||||
|
||||
async commit() {
|
||||
await this.client.query('COMMIT');
|
||||
}
|
||||
|
||||
async rollback() {
|
||||
await this.client.query('ROLLBACK');
|
||||
}
|
||||
|
||||
release() {
|
||||
this.client.release();
|
||||
}
|
||||
}
|
||||
|
||||
class PostgresPool {
|
||||
constructor(config) {
|
||||
// Convert connection string from postgresql:// to pool config
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
this.pool = new Pool({
|
||||
connectionString: connectionString || `postgresql://${config.user}:${config.password}@${config.host}:5432/${config.database}`,
|
||||
ssl: connectionString && connectionString.includes('localhost') ? false : { rejectUnauthorized: false }
|
||||
});
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.pool.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const client = await this.pool.connect();
|
||||
return new PostgresConnection(client);
|
||||
}
|
||||
|
||||
async end() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createPool: (config) => new PostgresPool(config)
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Beneficiario {
|
||||
id String @id @default(uuid())
|
||||
nome String
|
||||
identificacao String @unique
|
||||
guias GuiaOdontologica[]
|
||||
gtoBuilders GtoBuilder[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GuiaOdontologica {
|
||||
id String @id @default(uuid())
|
||||
numeroGuiaPrestador String
|
||||
numeroGuiaOperadora String?
|
||||
dataSolicitacao DateTime
|
||||
tipoTratamento String
|
||||
status StatusGuia
|
||||
beneficiarioId String
|
||||
beneficiario Beneficiario @relation(fields: [beneficiarioId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoBuilder {
|
||||
id String @id @default(uuid())
|
||||
pacienteId String
|
||||
paciente Beneficiario @relation(fields: [pacienteId], references: [id])
|
||||
dentistaId String
|
||||
credenciadaId String
|
||||
status String @default("RASCUNHO") // RASCUNHO | FINALIZADA
|
||||
total Float @default(0)
|
||||
items GtoItem[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoItem {
|
||||
id String @id @default(uuid())
|
||||
builderId String
|
||||
builder GtoBuilder @relation(fields: [builderId], references: [id])
|
||||
procedimentoId String
|
||||
codigoTUSS String
|
||||
descricao String
|
||||
dente String?
|
||||
face String?
|
||||
quantidade Int @default(1)
|
||||
valorUnitario Float
|
||||
valorTotal Float
|
||||
observacao String?
|
||||
anexosCount Int @default(0)
|
||||
}
|
||||
|
||||
enum StatusGuia {
|
||||
EM_ANALISE
|
||||
AUTORIZADO
|
||||
AUTORIZADO_PARCIAL
|
||||
NEGADO
|
||||
CANCELADO
|
||||
}
|
||||
+1578
File diff suppressed because it is too large
Load Diff
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');
|
||||
@@ -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
|
||||
@@ -0,0 +1,87 @@
|
||||
# GUIA DE DEPLOY - SCOREODONTO CRM (VPS)
|
||||
|
||||
Este guia detalha o processo de implantação do ScoreOdonto CRM em um servidor VPS (Ubuntu/Debian recomendado).
|
||||
|
||||
## 1. Pré-requisitos
|
||||
* Node.js (v18+) e NPM.
|
||||
* MySQL Server.
|
||||
* Nginx (como Proxy Reverso).
|
||||
* PM2 (Gerenciador de Processos).
|
||||
|
||||
## 2. Configuração do Banco de Dados
|
||||
No servidor MySQL da VPS, crie o banco e as tabelas:
|
||||
```sql
|
||||
CREATE DATABASE sis_odonto CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
Importe seus dados usando o `SyncView` ou o script de atualização após o deploy.
|
||||
|
||||
## 3. Preparação do Ambiente
|
||||
Clone o repositório na VPS e instale as dependências:
|
||||
```bash
|
||||
cd /var/www/scoreodonto
|
||||
npm install
|
||||
```
|
||||
|
||||
Crie o arquivo `.env` na pasta raiz (`scoreodonto/`):
|
||||
```env
|
||||
PORT=3002
|
||||
DB_HOST=localhost
|
||||
DB_USER=seu_usuario
|
||||
DB_PASSWORD=sua_senha
|
||||
DB_NAME=sis_odonto
|
||||
```
|
||||
|
||||
## 4. Build do Frontend
|
||||
Gere os arquivos estáticos para produção:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Isso criará a pasta `dist` (ou `build`).
|
||||
|
||||
## 5. Gerenciamento com PM2
|
||||
Inicie o servidor backend:
|
||||
```bash
|
||||
pm2 start server.js --name "scoreodonto-api"
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 6. Configuração do Nginx
|
||||
Edite o arquivo de configuração do site no Nginx (`/etc/nginx/sites-available/scoreodonto`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
root /var/www/scoreodonto/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
Ative o site e reinicie o Nginx:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/scoreodonto /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
## 7. Ajustes de Produção
|
||||
* **API URL**: O frontend agora usa o `API_URL` relativo ou configurado via environment. Como o Nginx está redirecionando `/api` para o backend, o sistema funcionará corretamente de forma transparente.
|
||||
* **Notificações**: Estão unificadas no topo de cada página para fácil acesso.
|
||||
* **Segurança**: Certifique-se de configurar um certificado SSL (Certbot/Let's Encrypt).
|
||||
|
||||
---
|
||||
*Gerado automaticamente pelo Antigravity.*
|
||||
@@ -0,0 +1,80 @@
|
||||
# 📒 Guia Estratégico e Arquitetura da Automação
|
||||
|
||||
Este documento detalha as escolhas técnicas e o funcionamento estratégico da automação desenvolvida para o **Portal Viventeris (Cassems)**, explicando "o porquê" de cada decisão.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 1. Arquitetura: Por que Playwright e não Axios?
|
||||
|
||||
Ao desenvolver automações para portais de saúde (TISS/SADT), escolhemos o **Playwright** em vez de ferramentas de requisição direta como o **Axios**. Aqui está a justificativa:
|
||||
|
||||
### Playwright (Navegador Real) - *Nossa Escolha*
|
||||
- **Simulação Humana**: Ele carrega o JavaScript do portal, processa pop-ups e lida com o "autocomplete" do jQuery UI (essencial para que o sistema valide o procedimento).
|
||||
- **Gestão de Sessão**: O portal Viventeris possui tokens de segurança e cookies expiráveis. O navegador gerencia isso automaticamente.
|
||||
- **Resiliência Visual**: Se o portal mudar a posição de um campo, a automação baseada em `id` ou `label` continua funcionando.
|
||||
- **Monitoramento**: Você pode ver o navegador trabalhando, o que facilita identificar se o sistema da Cassems está lento ou fora do ar.
|
||||
|
||||
### Axios/API (Requisições Ocultas) - *Descartado*
|
||||
- **Extrema Complexidade**: Exigiria descobrir todos os "requests" ocultos que o servidor espera.
|
||||
- **Fragilidade**: Qualquer mudança na segurança do servidor quebraria a integração sem deixar rastro visual.
|
||||
- **Bloqueios**: Sistemas modernos de saúde detectam requisições "secas" (sem navegador) e bloqueiam o acesso por suspeita de ataque.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 2. Inteligência do Fluxo (Regras de Negócio)
|
||||
|
||||
O script foi treinado para seguir regras específicas de UX do portal:
|
||||
|
||||
1. **Regra de Urgência**:
|
||||
- O sistema monitora o select `#tipoAtendimento`.
|
||||
- Se selecionado **Urgência (ID 4)**, ele obrigatoriamente preenche a `Justificativa`.
|
||||
- Se selecionado **Eletivo (ID 1)**, ele pula o campo para evitar erros de validação.
|
||||
2. **Lógica do Autocomplete**:
|
||||
- Digitar o código do procedimento não é suficiente. O script foi programado para **clicar no item da lista suspensa** que o sistema sugere. Sem esse clique, o sistema muitas vezes "limpa" o campo ao tentar avançar.
|
||||
3. **Superação de Sobreposição**:
|
||||
- No campo de **Faces (V, M, D, L/P)**, o script utiliza cliques forçados nas labels, pois os checkboxes originais são ocultados por CSS de design do portal.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 3. Manutenção e Boas Práticas
|
||||
|
||||
### Como manter o código funcionando:
|
||||
- **IDs Estáveis**: Sempre que possível, usamos o `id` (ex: `#cmbDente`). Se o portal mudar o layout, mas mantiver os IDs, o código **não quebra**.
|
||||
- **Timeouts**: Configuramos esperas inteligentes. O script não trava se a internet oscilar por 2 segundos; ele aguarda o elemento aparecer.
|
||||
|
||||
### Segurança dos Dados:
|
||||
- **Credenciais**: Atualmente fixas no código para testes. Em produção, recomenda-se usar variáveis de ambiente ou um arquivo de configuração externo.
|
||||
- **Breakpoint de Erro**: O código possui a função `catch`. Se algo der errado (ex: beneficiário bloqueado), o script **para tudo**, tira um print da tela (`erro-guia-odonto.png`) e mantém o navegador aberto para você ver o motivo.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. Infraestrutura Atual (VM Windows)
|
||||
|
||||
Como estamos operando dentro de uma Máquina Virtual, optamos pela instalação nativa para garantir performance e estabilidade:
|
||||
|
||||
| Recurso | Status | Host:Porta | Credenciais | Gerenciamento |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **MySQL (9.6)** | Ativo (Serviço) | `localhost:3306` | User: `root` / Pass: (vazio) | MySQL CLI / DBeaver |
|
||||
| **Redis (8.6)** | Ativo (PM2) | `localhost:6379` | s/ senha | `pm2 monit` / `redis-cli` |
|
||||
| **Python (3.14)**| Ativo | - | - | `python --version` |
|
||||
| **PM2** | Ativo | - | - | `pm2 list` |
|
||||
|
||||
### Comandos de Manutenção:
|
||||
- **Verificar Redis**: `pm2 list` ou `redis-cli ping`
|
||||
- **Verificar MySQL**: `Get-Service MySQL`
|
||||
- **Logs do Redis**: `pm2 logs redis-server`
|
||||
|
||||
---
|
||||
|
||||
## 📋 5. Comandos Úteis
|
||||
|
||||
| Objetivo | Comando |
|
||||
| :--- | :--- |
|
||||
| **Instalar Dependências** | `npm install` |
|
||||
| **Rodar Lançamento** | `node lancar-guia-odonto.js` |
|
||||
| **Verificar Versão Node** | `node -v` |
|
||||
| **Logs do Processo** | `pm2 logs` |
|
||||
|
||||
---
|
||||
|
||||
**Conclusão**: Esta automação transforma um processo de 5 minutos de cliques manuais em uma tarefa de 20 segundos totalmente segura e auditável.
|
||||
@@ -0,0 +1,63 @@
|
||||
# 🦷 Projeto de Automação: Portal Viventeris (Cassems)
|
||||
|
||||
Este documento resume a estrutura e a lógica da automação desenvolvida para o lançamento de guias odontológicas no portal Viventeris.
|
||||
|
||||
## 🚀 Visão Geral
|
||||
A automação utiliza **Node.js** e **Playwright** para simular a interação humana com o portal, superando desafios de interface (UX) e automação de processos repetitivos.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tecnologias Utilizadas
|
||||
- **Linguagem**: JavaScript (Node.js)
|
||||
- **Engine**: Playwright (Chromium)
|
||||
- **Estratégia**: Interação via Seletores de ID, XPath e Filtros de Visibilidade.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Fluxo de Automação Compreendido
|
||||
|
||||
### 1. Acesso e Segurança
|
||||
- **Login**: Autenticação automática com User/Senha.
|
||||
- **User-Agent**: Configurado para evitar o bloqueio de "Navegador Incompatível".
|
||||
|
||||
### 2. Identificação do Beneficiário
|
||||
- Navega até o menu **Tratamento Odontológico**.
|
||||
- Realiza a busca via **CPF** (formato: `000.000.000-00`).
|
||||
- Seleciona o beneficiário clicando no link do cartão retornado na tabela.
|
||||
|
||||
### 3. Configuração do Atendimento
|
||||
- **Novo Registro**: Clica no botão `+` (`#btnNovoRegistro`).
|
||||
- **Endereço**: Seleciona automaticamente o primeiro endereço disponível se houver um select.
|
||||
- **Tipo de Atendimento**:
|
||||
- `Valor 1`: Tratamento Odontológico (Normal).
|
||||
- `Valor 4`: Urgência / Emergência.
|
||||
- **Regra de Justificativa**:
|
||||
- O campo `Observação/Justificativa` (`#observacaoJustificativa`) só é preenchido se o tipo for **Urgência (4)**. Caso contrário, permanece vazio.
|
||||
|
||||
### 4. Seleção de Profissionais
|
||||
- **Executante**: Utiliza o botão de busca (`#btnBuscaPrestadorExecutante`) ao lado do input.
|
||||
- **Solicitante**: Preenchimento fixo/variável do campo de texto (ex: "Murilo").
|
||||
|
||||
### 5. Lançamento de Procedimentos (Modal)
|
||||
- **Autocomplete**: Após digitar o código (ex: `85100196`), o script aguarda e clica obrigatoriamente no item da lista jQuery UI (`.ui-menu-item-wrapper`) para que o sistema valide a seleção.
|
||||
- **Dente**: Seleção via ID `#cmbDente`.
|
||||
- **Face**: Clique forçado no checkbox `#codFaceV` para evitar interceptação pela label de estilo.
|
||||
- **Inclusão**: Clica em `#btnIncluirProcedimento` dentro do modal.
|
||||
|
||||
### 6. Finalização e Captura
|
||||
- Avança as etapas de confirmação ("Próximo").
|
||||
- Extrai o número da **GTO** gerada e o **Status** (Autorizado/Negado).
|
||||
- Salva o resultado no arquivo `guias_geradas.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estrutura de Arquivos
|
||||
- `login-test.js`: Validação inicial de acesso.
|
||||
- `lancar-guia-odonto.js`: Script principal de produção.
|
||||
- `guias_geradas.txt`: Log histórico de GTOs geradas.
|
||||
- `procedimentos_encontrados.txt`: Dicionário de códigos capturados durante a exploração.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Observação Importante sobre Erros
|
||||
Conforme alinhado, o script possui **breakpoints de segurança**. Caso um elemento (como o nome de um profissional específico ou um campo novo) não seja encontrado, o script **PARA IMEDIATAMENTE** e aguarda intervenção manual, evitando o preenchimento de dados incorretos no portal oficial.
|
||||
@@ -0,0 +1,49 @@
|
||||
const axios = require('axios');
|
||||
const qs = require('qs');
|
||||
|
||||
async function testAxiosLogin() {
|
||||
console.log('🧪 Testando Login via Axios...');
|
||||
|
||||
const loginUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br/credenciado/Account/AutenticarCredenciado/?returnUrl=';
|
||||
|
||||
// Dados capturados no mapeamento
|
||||
const payload = qs.stringify({
|
||||
'usuario': '0005010',
|
||||
'senha': 'Cclinic#03',
|
||||
'token': '',
|
||||
'login-submit': 'ENTRAR'
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(loginUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Origin': 'https://viventerisportalcredenciado.topsaudehub.com.br',
|
||||
'Referer': 'https://viventerisportalcredenciado.topsaudehub.com.br/'
|
||||
},
|
||||
maxRedirects: 0, // Queremos ver para onde ele nos manda
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
console.log('\n✅ Login enviado com sucesso!');
|
||||
console.log('Status:', response.status);
|
||||
console.log('Cookies recebidos:', response.headers['set-cookie']);
|
||||
console.log('Redirecionado para:', response.headers.location);
|
||||
|
||||
if (response.headers.location && response.headers.location.includes('AreaLogada')) {
|
||||
console.log('\n🔥 SUCESSO! O servidor aceitou o login via Axios.');
|
||||
} else {
|
||||
console.log('\n⚠️ O login parece ter sido recusado ou mudou de caminho.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Erro no Login Axios:', error.message);
|
||||
if (error.response) {
|
||||
console.log('Status do Erro:', error.response.status);
|
||||
console.log('Data:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testAxiosLogin();
|
||||
@@ -0,0 +1,217 @@
|
||||
const axios = require('axios');
|
||||
const qs = require('qs');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// Configurações
|
||||
const CREDENTIALS = {
|
||||
usuario: '0005010',
|
||||
senha: 'Cclinic#03'
|
||||
};
|
||||
|
||||
const DB_CONFIG = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'cassems_automation'
|
||||
};
|
||||
|
||||
class CassemsRobot {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://viventeris.topsaudehub.com.br/api/APICredenciado/api',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
}
|
||||
});
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
console.log('🔐 Autenticando no portal...');
|
||||
const loginUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br/credenciado/Account/AutenticarCredenciado/?returnUrl=';
|
||||
|
||||
const payload = qs.stringify({
|
||||
...CREDENTIALS,
|
||||
'token': '',
|
||||
'login-submit': 'ENTRAR'
|
||||
});
|
||||
|
||||
const res = await axios.post(loginUrl, payload, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
maxRedirects: 0,
|
||||
validateStatus: s => s < 400
|
||||
});
|
||||
|
||||
// Extrair Token do Cookie
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const tokenCookie = cookies.find(c => c.includes('TOKEN=bearer'));
|
||||
|
||||
if (!tokenCookie) {
|
||||
console.log('Cookies recebidos:', cookies);
|
||||
throw new Error('Token não encontrado nos cookies de resposta.');
|
||||
}
|
||||
|
||||
// Decodificar o token da URL (substituir %20 por espaço se necessário)
|
||||
const rawToken = tokenCookie.split('TOKEN=')[1].split(';')[0];
|
||||
this.token = decodeURIComponent(rawToken).replace('bearer ', '');
|
||||
|
||||
this.client.defaults.headers.common['Authorization'] = `bearer ${this.token}`;
|
||||
|
||||
console.log('✅ Autenticado com sucesso! Token JWT capturado.');
|
||||
}
|
||||
|
||||
async buscarBeneficiario(cpf) {
|
||||
const cpfLimpo = cpf.replace(/\D/g, '');
|
||||
console.log(`🔍 Buscando beneficiário CPF: ${cpf}...`);
|
||||
|
||||
const payload = {
|
||||
codPrestadorTs: "501",
|
||||
numAssociado: "",
|
||||
numCpf: cpfLimpo,
|
||||
numRg: "",
|
||||
nomeAssociado: "",
|
||||
numPedido: "",
|
||||
numProtocoloAns: "",
|
||||
tipoPrestador: "D"
|
||||
};
|
||||
|
||||
const res = await this.client.post('/beneficiario/buscaBeneficiario', payload);
|
||||
|
||||
if (res.data && res.data.length > 0) {
|
||||
const b = res.data[0];
|
||||
console.log('DEBUG - Estrutura do Beneficiário:', JSON.stringify(b, null, 2));
|
||||
|
||||
// Mapeamento correto conforme o DEBUG
|
||||
b.nome_final = b.nome_associado || b.NOM_PESSOA || "NOME DESCONHECIDO";
|
||||
b.id_final = b.cod_ts || b.COD_TS || "ID DESCONHECIDO";
|
||||
|
||||
console.log(`👤 Beneficiário Encontrado: ${b.nome_final} (ID: ${b.id_final})`);
|
||||
return b;
|
||||
}
|
||||
|
||||
console.log('⚠️ Beneficiário não encontrado.');
|
||||
return null;
|
||||
}
|
||||
|
||||
async lancarGuia(beneficiario, procedimento, dente, face) {
|
||||
console.log(`🚀 Lançando guia para ${beneficiario.nome_final}...`);
|
||||
|
||||
const dataHoje = new Date().toLocaleDateString('pt-BR');
|
||||
|
||||
const payload = {
|
||||
"dadosAtendimento": [{
|
||||
"codPrestadorTs": "501",
|
||||
"codTs": beneficiario.cod_ts,
|
||||
"numAssociado": beneficiario.num_associado,
|
||||
"nomeAssociado": beneficiario.nome_associado,
|
||||
"indSexo": beneficiario.indSexo || "",
|
||||
"codPlano": beneficiario.codPlano || "",
|
||||
"codEmpresa": beneficiario.codEmpresa || "",
|
||||
"numCPF": beneficiario.cpf || "",
|
||||
"numContrato": beneficiario.numContrato || "",
|
||||
"numAssociadoTitular": beneficiario.numAssociadoTitular || "",
|
||||
"nomeAssociadoTitular": beneficiario.nomeAssociadoTitular || "",
|
||||
"nomeContrato": beneficiario.nomeContrato || "",
|
||||
"num_ddd_cel_contato": "",
|
||||
"num_cel_contato": "",
|
||||
"num_ddd_res_contato": "",
|
||||
"num_res_contato": "",
|
||||
"txt_end_email_contato": ""
|
||||
}],
|
||||
"dadosProfissional": [{
|
||||
"codProExecutanteTs": "502",
|
||||
"atendimentoRN": "N",
|
||||
"enderecoAtendimento": "1",
|
||||
"cnes": "9999999",
|
||||
"dataSolicitacao": dataHoje,
|
||||
"tipoAtendimento": "1",
|
||||
"nomeProfissionalExecutante": "RUI CESAR VARGAS CASANOVA",
|
||||
"croExecutante": "07381",
|
||||
"ufExecutante": "MS",
|
||||
"cboExecutante": "",
|
||||
"nomeProfissionalSolicitante": "Murilo",
|
||||
"croSolicitante": "",
|
||||
"ufSolicitante": "",
|
||||
"codCboSolicitante": "",
|
||||
"observacaoJustificativa": ""
|
||||
}],
|
||||
"autorizacaoItem": [{
|
||||
"num_seq_item": 1,
|
||||
"item_medico": procedimento,
|
||||
"nome_item": "PROCEDIMENTO VIA ROBÔ",
|
||||
"dente_regiao": dente,
|
||||
"cod_face": face,
|
||||
"ind_rx_odonto": "N",
|
||||
"ind_foto": "N",
|
||||
"ind_laudo": "N",
|
||||
"qtd_procedimento": "1"
|
||||
}],
|
||||
"situacaoInicial": [],
|
||||
"ortodontiaPartial": [],
|
||||
"prorrogacaoPartial": [],
|
||||
"ExisteProrrogacao": "N",
|
||||
"ExisteOrtodontia": "N",
|
||||
"TratamentoTransferido": "N",
|
||||
"NumeroTratamento": "",
|
||||
"CodUsuario": "0005010",
|
||||
"NumSeqPedidoUltimo": ""
|
||||
};
|
||||
|
||||
const res = await this.client.post('/autorizacao/NovoTratamento', payload);
|
||||
|
||||
if (res.data) {
|
||||
console.log('✅ Guia Processada com Sucesso!');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
throw new Error('Falha no processamento da guia.');
|
||||
}
|
||||
|
||||
async registrarNoBanco(dados) {
|
||||
const connection = await mysql.createConnection(DB_CONFIG);
|
||||
await connection.execute(
|
||||
'INSERT INTO guias_pendentes (cpf, procedimento, dente, face, status, gto) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[dados.cpf, dados.procedimento, dados.dente, dados.face, dados.status, dados.gto || null]
|
||||
);
|
||||
console.log('💾 Registro salvo no MySQL.');
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Execução de Teste
|
||||
(async () => {
|
||||
const robot = new CassemsRobot();
|
||||
try {
|
||||
await robot.login();
|
||||
|
||||
const cpf = '005.983.051-49';
|
||||
const proc = '85100196';
|
||||
const dente = '11';
|
||||
const face = 'V';
|
||||
|
||||
const beneficiario = await robot.buscarBeneficiario(cpf);
|
||||
|
||||
if (beneficiario) {
|
||||
const resultado = await robot.lancarGuia(beneficiario, proc, dente, face);
|
||||
console.log('Dados do Retorno da API:', JSON.stringify(resultado, null, 2));
|
||||
|
||||
// Tenta capturar o número da guia se existir no JSON
|
||||
const numeroGuia = resultado.NumeroTratamento || resultado.numGuia || "VER_LOG";
|
||||
|
||||
await robot.registrarNoBanco({
|
||||
cpf: cpf,
|
||||
procedimento: proc,
|
||||
dente: dente,
|
||||
face: face,
|
||||
status: 'AUTORIZADO (AXIOS)',
|
||||
gto: numeroGuia
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n🔥 SUCESSO TOTAL! Guia lançada via API em milissegundos.');
|
||||
} catch (e) {
|
||||
console.error('❌ Erro no Robô:', e.message);
|
||||
if (e.response) console.log('Detalhes:', JSON.stringify(e.response.data, null, 2));
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
console.log("Connected to DB");
|
||||
|
||||
try {
|
||||
const [users] = await connection.query('SELECT count(*) as count FROM usuarios');
|
||||
console.log("Users count:", users[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'usuarios' not found or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const [links] = await connection.query('SELECT count(*) as count FROM vinculos');
|
||||
console.log("Links count:", links[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'vinculos' not found or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const [clinics] = await connection.query('SELECT count(*) as count FROM clinicas');
|
||||
console.log("Clinics count:", clinics[0].count);
|
||||
} catch (e) {
|
||||
console.log("Table 'clinicas' not found or error:", e.message);
|
||||
}
|
||||
|
||||
const [usersData] = await connection.query('SELECT id, email, senha FROM usuarios');
|
||||
console.log("Users in DB (email/password):");
|
||||
usersData.forEach(u => console.log(`- ${u.email} / ${u.senha}`));
|
||||
|
||||
for (const user of usersData) {
|
||||
const [userLinks] = await connection.query('SELECT * FROM vinculos WHERE usuario_id = ?', [user.id]);
|
||||
console.log(`Links for ${user.email} (ID: ${user.id}):`, userLinks.map(l => ({ clinica: l.clinica_id, role: l.role })));
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
} catch (err) {
|
||||
console.error("DB Check failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
@@ -0,0 +1,69 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
async function fix() {
|
||||
try {
|
||||
const c = await mysql.createConnection(dbConfig);
|
||||
console.log("Connected for fixing collations...");
|
||||
|
||||
const tables = [
|
||||
'usuarios', 'clinicas', 'vinculos', 'dentistas', 'agendamentos',
|
||||
'pacientes', 'financeiro', 'planos', 'especialidades', 'procedimentos',
|
||||
'procedimentos_valores_planos', 'guias_odontologicas', 'gto_builders', 'gto_items', 'notificacoes', 'settings'
|
||||
];
|
||||
|
||||
for (const t of tables) {
|
||||
try {
|
||||
// Remove FK checks to avoid issues during alter
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
await c.query(`ALTER TABLE ${t} CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
console.log(`[OK] ${t} normalized.`);
|
||||
} catch (err) {
|
||||
console.log(`[SKIP] ${t}: ${err.message}`);
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
}
|
||||
|
||||
// Link unassigned dentists (professionals) to the main clinic
|
||||
await c.query('UPDATE dentistas SET clinica_id = "c1" WHERE clinica_id IS NULL OR clinica_id = ""');
|
||||
console.log("All dentists linked to c1 (Matriz).");
|
||||
|
||||
// Now get the division
|
||||
const [division] = await c.query(`
|
||||
SELECT
|
||||
c.nome_fantasia as Clinica,
|
||||
u.nome as Nome,
|
||||
v.role as Papel,
|
||||
u.email as Email
|
||||
FROM vinculos v
|
||||
JOIN usuarios u ON v.usuario_id = u.id
|
||||
JOIN clinicas c ON v.clinica_id = c.id
|
||||
WHERE v.role IN ('dentista', 'donoclinica')
|
||||
ORDER BY c.nome_fantasia
|
||||
`);
|
||||
|
||||
console.log("\n--- DIVISÃO DE DENTISTAS/DONOS POR CLÍNICA ---");
|
||||
console.table(division);
|
||||
|
||||
const [professionals] = await c.query(`
|
||||
SELECT d.nome, c.nome_fantasia as clinica
|
||||
FROM dentistas d
|
||||
JOIN clinicas c ON d.clinica_id = c.id
|
||||
`);
|
||||
console.log("\n--- REGISTROS PROFISSIONAIS (LISTA DE DENTISTAS) ---");
|
||||
console.table(professionals);
|
||||
|
||||
await c.end();
|
||||
} catch (err) {
|
||||
console.error("Fix failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
fix();
|
||||
@@ -0,0 +1,40 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
async function run() {
|
||||
const c = await mysql.createConnection({
|
||||
host: 'localhost', user: 'root', password: '', database: 'sis_odonto'
|
||||
});
|
||||
|
||||
// 1. Seed missing plans (those referenced by existing procedimentos_valores_planos)
|
||||
const plans = [
|
||||
{ id: 'p2', nome: 'UNIMED ODONTO', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#1eb545' },
|
||||
{ id: 'p3', nome: 'ODONTOPREV', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#0066cc' },
|
||||
{ id: 'p4', nome: 'CASSEMS', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#e63946' },
|
||||
{ id: 'p5', nome: 'SESI ODONTO', tipo: 'Convenio', descontoPadrao: 0, corCartao: '#f4a261' },
|
||||
];
|
||||
|
||||
for (const p of plans) {
|
||||
await c.query(
|
||||
'INSERT IGNORE INTO planos (id, nome, tipo, descontoPadrao, corCartao) VALUES (?, ?, ?, ?, ?)',
|
||||
[p.id, p.nome, p.tipo, p.descontoPadrao, p.corCartao]
|
||||
);
|
||||
}
|
||||
console.log('Plans seeded.');
|
||||
|
||||
// 2. Drop the FK on planoId to make the relation soft (plan can be deleted without cascading)
|
||||
// This prevents the ER_NO_REFERENCED_ROW_2 on INSERT
|
||||
try {
|
||||
await c.query('ALTER TABLE procedimentos_valores_planos DROP FOREIGN KEY procedimentos_valores_planos_ibfk_2');
|
||||
console.log('FK on planoId dropped — now soft reference.');
|
||||
} catch (e) {
|
||||
console.log('FK drop skipped (may already not exist):', e.message);
|
||||
}
|
||||
|
||||
const [planos] = await c.query('SELECT id, nome, tipo FROM planos');
|
||||
console.log('\n--- PLANOS FINAIS ---');
|
||||
console.table(planos);
|
||||
|
||||
await c.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
TITLE Iniciar ScoreOdonto CRM
|
||||
echo.
|
||||
echo ==================================================
|
||||
echo INICIANDO SISTEMA SCOREODONTO - WINDOWS
|
||||
echo ==================================================
|
||||
echo.
|
||||
|
||||
:: 1. Entrar na pasta do projeto
|
||||
cd /d "%~dp0scoreodonto"
|
||||
|
||||
:: 2. Iniciar a API em uma nova janela
|
||||
echo [INFO] Iniciando Servidor API (Porta 3002)...
|
||||
start "" cmd /k "node server.js"
|
||||
|
||||
:: 3. Aguardar alguns segundos para a API estabilizar
|
||||
timeout /t 3 /nobreak > nul
|
||||
|
||||
:: 4. Iniciar o Frontend (Vite) na janela atual
|
||||
echo [INFO] Iniciando Frontend (Porta 3000)...
|
||||
echo.
|
||||
echo [DICA] O sistema abrira em: http://localhost:3000
|
||||
echo [DICA] Para fechar, feche as janelas do terminal.
|
||||
echo.
|
||||
npm run dev
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: cassems-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: cassems_automation
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
container_name: cassems-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MedLandingPage } from './LandingPage.tsx';
|
||||
import { MedLayout } from './MedLayout.tsx';
|
||||
import { MedDashboard } from './MedDashboard.tsx';
|
||||
import { MedicoBackend } from './services/medicoBackend.ts';
|
||||
|
||||
export type MedViewKey =
|
||||
| 'med-landing'
|
||||
| 'med-dashboard'
|
||||
| 'med-pacientes'
|
||||
| 'med-agenda'
|
||||
| 'med-prontuarios'
|
||||
| 'med-fidelidade'
|
||||
| 'med-financeiro'
|
||||
| 'medical-repasse';
|
||||
|
||||
const ROUTE_MAP: Record<string, MedViewKey> = {
|
||||
'/': 'med-landing',
|
||||
'/landingpage': 'med-landing',
|
||||
'/dashboard': 'med-dashboard',
|
||||
'/pacientes': 'med-pacientes',
|
||||
'/agenda': 'med-agenda',
|
||||
'/prontuarios': 'med-prontuarios',
|
||||
'/fidelidade': 'med-fidelidade',
|
||||
'/financeiro': 'med-financeiro',
|
||||
'/repasse': 'medical-repasse',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<MedViewKey, string> = {
|
||||
'med-landing': '/landingpage',
|
||||
'med-dashboard': '/dashboard',
|
||||
'med-pacientes': '/pacientes',
|
||||
'med-agenda': '/agenda',
|
||||
'med-prontuarios': '/prontuarios',
|
||||
'med-fidelidade': '/fidelidade',
|
||||
'med-financeiro': '/financeiro',
|
||||
'medical-repasse': '/repasse',
|
||||
};
|
||||
|
||||
function getHashPath(): string {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/')) return hash.slice(1);
|
||||
return '/';
|
||||
}
|
||||
|
||||
function resolveViewFromUrl(): MedViewKey {
|
||||
const path = getHashPath();
|
||||
return ROUTE_MAP[path] ?? 'med-landing';
|
||||
}
|
||||
|
||||
function navigate(view: MedViewKey) {
|
||||
window.location.hash = VIEW_TO_ROUTE[view];
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentView, setCurrentView] = useState<MedViewKey>(resolveViewFromUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
setCurrentView(resolveViewFromUrl());
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
const handleNavigate = (view: string) => {
|
||||
const key = view as MedViewKey;
|
||||
setCurrentView(key);
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'med-landing': return <MedLandingPage />;
|
||||
case 'med-dashboard': return <MedDashboard />;
|
||||
case 'med-pacientes': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Módulo de Pacientes Médicos <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-agenda': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Agenda Médica Premium <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-prontuarios': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Sistema de Prontuários <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-fidelidade': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Gestão de Fidelidade <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'med-financeiro': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Financeiro & MRR <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
case 'medical-repasse': return <div className="text-white p-20 text-center font-black uppercase tracking-widest text-2xl">Repasse Médico <br /><span className="text-cyan-400 text-sm">Em Desenvolvimento</span></div>;
|
||||
default: return <MedLandingPage />;
|
||||
}
|
||||
};
|
||||
|
||||
if (currentView === 'med-landing') {
|
||||
return <MedLandingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MedLayout currentView={currentView} onNavigate={handleNavigate}>
|
||||
{renderView()}
|
||||
</MedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Shield, Activity, Heart, Check, ArrowRight, Star, Clock, Users, Smartphone, Zap } from 'lucide-react';
|
||||
|
||||
export const MedLandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white font-sans selection:bg-cyan-500 selection:text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-slate-900/50 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tighter uppercase italic">CRM MÉDICO <span className="text-cyan-400">FIDELIDADE</span></span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-bold uppercase tracking-widest text-slate-400">
|
||||
<a href="#planos" className="hover:text-white transition-colors">Planos</a>
|
||||
<a href="#beneficios" className="hover:text-white transition-colors">Benefícios</a>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="hover:text-white transition-colors uppercase"
|
||||
>
|
||||
Acesso Restrito
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="px-6 py-2.5 bg-white text-slate-900 rounded-full hover:bg-cyan-400 transition-all hover:-translate-y-1"
|
||||
>
|
||||
Voltar ao CRM Odonto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-40 pb-20 overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-cyan-500/10 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-blue-600/10 rounded-full blur-[120px] animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-full text-xs font-black uppercase tracking-widest text-cyan-400 mb-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Zap size={14} /> Novo: Fidelidade Multimédica
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-black tracking-tighter leading-[0.9] text-white mb-8 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||
SAÚDE TOTAL, <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600">SEM COMPROMISSO.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 max-w-xl leading-relaxed mb-12 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200">
|
||||
O primeiro plano de fidelidade que integra medicina, odontologia e bem-estar em um único ecossistema. Fidelize sua saúde com tecnologia de ponta.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 animate-in fade-in slide-in-from-bottom-10 duration-1000 delay-500">
|
||||
<button className="px-10 py-5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-2xl font-black uppercase tracking-widest hover:scale-105 transition-all shadow-xl shadow-cyan-500/20 flex items-center gap-3">
|
||||
COMEÇAR AGORA <ArrowRight size={20} />
|
||||
</button>
|
||||
<button className="px-10 py-5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl font-black uppercase tracking-widest transition-all">
|
||||
VER PLANOS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
<section id="planos" className="py-32 bg-slate-900/40 relative">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">ESTRUTURA DE <span className="text-cyan-400">PLANOS</span></h2>
|
||||
<p className="text-slate-400 uppercase tracking-[0.3em] font-bold text-xs">Exclusivo: Combos que não aceitam desmembramento</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Base Plan */}
|
||||
<div className="group relative bg-white/5 border border-white/10 rounded-[3rem] p-12 hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute top-8 right-8 text-slate-700 font-black text-6xl group-hover:text-slate-600 transition-colors">01</div>
|
||||
<div className="mb-10">
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA BASE (OBRIGATÓRIO)</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + <br />ODONTO</h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Consultas Médicas ilimitadas',
|
||||
'Limpeza e Prevenção Dental',
|
||||
'Urgências 24h',
|
||||
'Rede Credenciada Platinum',
|
||||
'Descontos em Medicamentos'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 149</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all">
|
||||
SELECIONAR PLANO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Premium Plan */}
|
||||
<div className="group relative bg-white/5 border-2 border-cyan-500/50 rounded-[3rem] p-12 overflow-hidden hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="absolute top-8 right-8 text-cyan-900/30 font-black text-6xl group-hover:text-cyan-900/50 transition-colors">02</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-4 py-1.5 bg-gradient-to-r from-amber-400 to-orange-500 text-black text-[10px] font-black uppercase tracking-widest rounded-full">MAIS POPULAR</span>
|
||||
</div>
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA PREMIUM</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + ODONTO <br /><span className="text-cyan-400">+ PSICOLOGIA</span></h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Tudo da Linha Base',
|
||||
'Suporte Psicológico 24h',
|
||||
'Sessões de Terapia Mensais',
|
||||
'Check-up Executivo Anual',
|
||||
'Prioridade na Agenda (VIP)'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500 flex items-center justify-center text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 249</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-gradient-to-r from-cyan-500 to-blue-600 text-white rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] transition-all shadow-lg shadow-cyan-500/20">
|
||||
ASSINAR PREMIUM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features (CRM) */}
|
||||
<section id="beneficios" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ icon: Users, title: 'TITULAR E DEP.', desc: 'Gestão completa familiar com carteirinha individual digital.' },
|
||||
{ icon: Shield, title: 'ESTABILIDADE', desc: 'Previsibilidade total para o médico e segurança para o paciente.' },
|
||||
{ icon: Smartphone, title: 'WALLET DIGITAL', desc: 'QR Code e histórico médico sempre no seu bolso.' },
|
||||
{ icon: Zap, title: 'SEM GLOSA', desc: 'Processamento automático e repasse médico instantâneo.' }
|
||||
].map((feat, i) => (
|
||||
<div key={i} className="p-8 bg-white/2 hover:bg-white/5 border border-white/5 rounded-3xl transition-all">
|
||||
<feat.icon className="text-cyan-400 mb-6" size={32} />
|
||||
<h4 className="text-lg font-black tracking-widest uppercase mb-4">{feat.title}</h4>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{feat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-white/5 bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
||||
<Activity className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="font-black uppercase tracking-tighter">CRM MÉDICO</span>
|
||||
</div>
|
||||
<div className="text-slate-500 text-[10px] font-black uppercase tracking-[0.4em]">
|
||||
© 2026 SCOREODONTO HYBRID MEDICAL ENGINE. ALL RIGHTS RESERVED.
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Sair para Odonto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Painel Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-in {
|
||||
animation: animate-in 0.8s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users, Calendar, DollarSign, Activity,
|
||||
ArrowUpRight, ArrowDownRight, Zap,
|
||||
TrendingUp, AlertCircle, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { MedicoBackend } from './services/medicoBackend.ts';
|
||||
import { MedicoNotif } from './services/medicoNotif.ts';
|
||||
import { MedPaciente, MedAgendamento, MedNotificacao } from './types.ts';
|
||||
|
||||
export const MedDashboard: React.FC = () => {
|
||||
const [pacientes, setPacientes] = useState<MedPaciente[]>([]);
|
||||
const [agendamentos, setAgendamentos] = useState<MedAgendamento[]>([]);
|
||||
const [notificacoes, setNotificacoes] = useState<MedNotificacao[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await MedicoNotif.generateIntelligentAlerts();
|
||||
const [p, a, n] = await Promise.all([
|
||||
MedicoBackend.getPacientes(),
|
||||
MedicoBackend.getAgendamentos(),
|
||||
MedicoBackend.getNotificacoes()
|
||||
]);
|
||||
setPacientes(p);
|
||||
setAgendamentos(a);
|
||||
setNotificacoes(n.filter(notif => !notif.lida));
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Pacientes Ativos', value: pacientes.length.toString(), change: '+12%', positive: true, icon: Users, color: 'cyan' },
|
||||
{ label: 'Consultas Hoje', value: agendamentos.filter(a => a.start.includes(new Date().toISOString().split('T')[0])).length.toString(), change: '+4', positive: true, icon: Calendar, color: 'blue' },
|
||||
{ label: 'Receita Est. (MRR)', value: 'R$ 84.200', change: '+8%', positive: true, icon: DollarSign, color: 'emerald' },
|
||||
{ label: 'Alertas Ativos', value: notificacoes.length.toString(), change: '', positive: true, icon: Activity, color: 'purple' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-20 text-center text-cyan-500 font-black uppercase tracking-[0.5em] animate-pulse">Iniciando Motor Médico...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-white mb-2 uppercase italic">Painel <span className="text-cyan-400">Estratégico</span></h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Visão geral do ecossistema médico e fidelidade</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={i} className="bg-white/5 border border-white/10 p-8 rounded-[2rem] hover:bg-white/[0.08] transition-all group">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-4 rounded-2xl bg-${stat.color}-500/10 text-${stat.color}-400 group-hover:scale-110 transition-transform`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-[10px] font-black uppercase tracking-widest ${stat.positive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{stat.change && (stat.positive ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />)}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-slate-500 text-[10px] font-black uppercase tracking-[0.2em]">{stat.label}</span>
|
||||
<div className="text-3xl font-black text-white tracking-tighter">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-[2.5rem] overflow-hidden">
|
||||
<div className="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.3em] text-white">Próximos Atendimentos</h3>
|
||||
<button className="text-[10px] font-black text-cyan-400 hover:text-cyan-300 uppercase tracking-widest transition-colors">Ver Agenda Completa</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{agendamentos.length > 0 ? agendamentos.slice(0, 5).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-all cursor-pointer group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-800 flex flex-col items-center justify-center border border-white/5">
|
||||
<span className="text-[10px] font-black text-slate-500 leading-none">HRS</span>
|
||||
<span className="text-sm font-black text-white">{new Date(item.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight group-hover:text-cyan-400 transition-colors">{item.pacienteNome}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">{item.tipo}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest ${item.status === 'Confirmado' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<button className="p-2 text-slate-600 hover:text-white transition-colors">
|
||||
<ArrowUpRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="p-10 text-center text-slate-500 font-bold uppercase tracking-widest text-xs">Nenhum agendamento para hoje</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intelligent Insights */}
|
||||
<div className="bg-gradient-to-b from-cyan-600/20 to-transparent border border-cyan-500/20 rounded-[2.5rem] p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-cyan-500 rounded-2xl flex items-center justify-center text-white mb-8 shadow-lg shadow-cyan-500/20">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white tracking-tighter mb-4 italic uppercase">Inteligência <br />Médica</h3>
|
||||
<div className="space-y-6">
|
||||
{notificacoes.length > 0 ? notificacoes.slice(0, 2).map((notif, i) => (
|
||||
<div key={i} className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={14} className={notif.priority === 'high' ? 'text-rose-400' : 'text-amber-400'} />
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest ${notif.priority === 'high' ? 'text-rose-400' : 'text-amber-400'}`}>
|
||||
{notif.titulo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">{notif.mensagem}</p>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5 text-center">
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Sem alertas pendentes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="w-full mt-10 py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all flex items-center justify-center gap-3">
|
||||
Ver Insights <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MedSidebar } from './MedSidebar.tsx';
|
||||
import { Bell, Search, Settings, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface MedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentView: string;
|
||||
onNavigate: (view: any) => void;
|
||||
}
|
||||
|
||||
export const MedLayout: React.FC<MedLayoutProps> = ({ children, currentView, onNavigate }) => {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#020617] text-slate-200 overflow-hidden font-sans">
|
||||
{/* Sidebar */}
|
||||
<MedSidebar activeTab={currentView} setActiveTab={onNavigate} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 ml-72 flex flex-col h-screen overflow-hidden">
|
||||
{/* Premium Top Bar */}
|
||||
<header className="h-24 px-10 flex items-center justify-between border-b border-white/5 bg-[#020617]/50 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div className="flex items-center gap-6 flex-1 max-w-xl">
|
||||
<div className="relative group w-full">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-400 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="BUSCAR PACIENTES, AGENDAMENTOS OU GUIAS..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-6 text-xs font-bold uppercase tracking-widest focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.08] transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ icon: Bell, alert: true },
|
||||
{ icon: HelpCircle, alert: false },
|
||||
{ icon: Settings, alert: false }
|
||||
].map((item, i) => (
|
||||
<button key={i} className="w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-all relative">
|
||||
<item.icon size={20} className="text-slate-400" />
|
||||
{item.alert && <div className="absolute top-3 right-3 w-2.5 h-2.5 bg-cyan-500 border-2 border-[#020617] rounded-full"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-white/10 mx-2"></div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-2">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-cyan-500 block leading-none uppercase tracking-widest">Acesso Premium</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-tighter">Portal Médico Cassems</span>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-cyan-500 to-blue-600 p-[2px] shadow-lg shadow-cyan-500/20">
|
||||
<div className="w-full h-full rounded-full bg-slate-900 flex items-center justify-center text-xs font-black text-white">
|
||||
MED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Dashboard/View Container */}
|
||||
<main className="flex-1 overflow-y-auto p-10 custom-scrollbar">
|
||||
<div className="max-w-7xl mx-auto pb-20">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, ClipboardList,
|
||||
Activity, Shield, DollarSign, LogOut, Settings,
|
||||
ChevronRight, Zap, Heart, Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MedSidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const MedSidebar: React.FC<MedSidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const menuItems = [
|
||||
{ id: 'med-dashboard', label: 'Painel Geral', icon: LayoutDashboard },
|
||||
{ id: 'med-pacientes', label: 'Pacientes', icon: Users },
|
||||
{ id: 'med-agenda', label: 'Agenda Médica', icon: Calendar },
|
||||
{ id: 'med-prontuarios', label: 'Prontuários', icon: ClipboardList },
|
||||
{ id: 'med-fidelidade', label: 'Planos Fidelidade', icon: Shield },
|
||||
{ id: 'med-financeiro', label: 'Financeiro / MRR', icon: DollarSign },
|
||||
{ id: 'medical-repasse', label: 'Repasse Médico', icon: Zap },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('Deseja realmente sair do portal médico?')) {
|
||||
window.location.hash = '/medico/landingpage';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-[#0f172a] h-screen flex flex-col border-r border-white/5 fixed left-0 top-0 z-50 overflow-hidden">
|
||||
{/* Logo Section */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveTab('med-dashboard')}>
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20 group-hover:scale-110 transition-transform">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-black tracking-tighter text-white block leading-none italic">CRM <span className="text-cyan-400">MÉDICO</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Premium Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 space-y-2 overflow-y-auto py-4">
|
||||
<div className="px-4 mb-4">
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-[0.3em]">Navegação Principal</span>
|
||||
</div>
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3.5 rounded-xl transition-all group ${isActive
|
||||
? 'bg-gradient-to-r from-cyan-500/10 to-transparent border-l-4 border-cyan-500 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={20} className={isActive ? 'text-cyan-400' : 'group-hover:text-cyan-400 transition-colors'} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <ChevronRight size={14} className="text-cyan-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section / User Profile */}
|
||||
<div className="p-6 bg-slate-900/40 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 mb-6 px-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 border border-white/10 flex items-center justify-center text-cyan-400 font-black text-xs">
|
||||
DR
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-black text-white block truncate uppercase tracking-tighter">Dr. Profissional</span>
|
||||
<span className="text-[10px] text-cyan-500/80 font-bold uppercase tracking-widest">Médico Diretor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all text-xs font-bold uppercase tracking-widest">
|
||||
<Settings size={18} />
|
||||
Configurações
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sair do Portal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="px-8 py-4 bg-slate-950/50 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,211,238,0.5)]"></div>
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Sincronizado</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #020617;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CRM Médico | Fidelidade Multimédica</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
Generated
+2543
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "medico-crm",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
MedPaciente,
|
||||
MedProfissional,
|
||||
MedAgendamento,
|
||||
MedProntuario,
|
||||
MedFinanceiro,
|
||||
MedNotificacao
|
||||
} from '../types.ts';
|
||||
|
||||
const API_BASE_URL = '/api/med'; // New namespace for medical API
|
||||
|
||||
export class MedicoBackend {
|
||||
// --- STORAGE KEYS ---
|
||||
private static KEYS = {
|
||||
PACIENTES: 'med_pacientes',
|
||||
PROFISSIONAIS: 'med_profissionais',
|
||||
AGENDAMENTOS: 'med_agendamentos',
|
||||
PRONTUARIOS: 'med_prontuarios',
|
||||
FINANCEIRO: 'med_financeiro',
|
||||
NOTIFICACOES: 'med_notificacoes',
|
||||
AUTH: 'med_auth_token'
|
||||
};
|
||||
|
||||
// --- HELPERS ---
|
||||
private static async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem(this.KEYS.AUTH);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
|
||||
if (!response.ok) throw new Error(`API Error: ${response.statusText}`);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.warn(`API unavailable at ${path}, falling back to local storage.`, err);
|
||||
throw err; // Let the caller decide how to handle fallback
|
||||
}
|
||||
}
|
||||
|
||||
// --- PACIENTES ---
|
||||
static async getPacientes(): Promise<MedPaciente[]> {
|
||||
try {
|
||||
return await this.request<MedPaciente[]>('/pacientes');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.PACIENTES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async savePaciente(paciente: MedPaciente): Promise<void> {
|
||||
try {
|
||||
await this.request('/pacientes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(paciente)
|
||||
});
|
||||
} catch {
|
||||
const pacientes = await this.getPacientes();
|
||||
const index = pacientes.findIndex(p => p.id === paciente.id);
|
||||
if (index >= 0) pacientes[index] = paciente;
|
||||
else pacientes.push(paciente);
|
||||
localStorage.setItem(this.KEYS.PACIENTES, JSON.stringify(pacientes));
|
||||
}
|
||||
}
|
||||
|
||||
// --- AGENDA / PROFISSIONAIS ---
|
||||
static async getProfissionais(): Promise<MedProfissional[]> {
|
||||
try {
|
||||
return await this.request<MedProfissional[]>('/profissionais');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.PROFISSIONAIS);
|
||||
if (stored) return JSON.parse(stored);
|
||||
// Mock initial data if empty
|
||||
const mock: MedProfissional[] = [
|
||||
{ id: '1', nome: 'Dr. Ricardo Santos', crm: '12345-MS', especialidade: 'Cardiologia', email: 'ricardo@med.com', telefone: '67 9999-9999', ativo: true, corAgenda: '#22d3ee' },
|
||||
{ id: '2', nome: 'Dra. Ana Oliveira', crm: '67890-MS', especialidade: 'Pediatria', email: 'ana@med.com', telefone: '67 8888-8888', ativo: true, corAgenda: '#f43f5e' }
|
||||
];
|
||||
localStorage.setItem(this.KEYS.PROFISSIONAIS, JSON.stringify(mock));
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
|
||||
static async getAgendamentos(): Promise<MedAgendamento[]> {
|
||||
try {
|
||||
return await this.request<MedAgendamento[]>('/agendamentos');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.AGENDAMENTOS);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async saveAgendamento(agenda: MedAgendamento): Promise<void> {
|
||||
try {
|
||||
await this.request('/agendamentos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(agenda)
|
||||
});
|
||||
} catch {
|
||||
const data = await this.getAgendamentos();
|
||||
const index = data.findIndex(a => a.id === agenda.id);
|
||||
if (index >= 0) data[index] = agenda;
|
||||
else data.push(agenda);
|
||||
localStorage.setItem(this.KEYS.AGENDAMENTOS, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// --- FINANCEIRO ---
|
||||
static async getFinanceiro(): Promise<MedFinanceiro[]> {
|
||||
try {
|
||||
return await this.request<MedFinanceiro[]>('/financeiro');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.FINANCEIRO);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- NOTIFICACOES ---
|
||||
static async getNotificacoes(): Promise<MedNotificacao[]> {
|
||||
try {
|
||||
return await this.request<MedNotificacao[]>('/notificacoes');
|
||||
} catch {
|
||||
const stored = localStorage.getItem(this.KEYS.NOTIFICACOES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
}
|
||||
|
||||
static async markNotificationRead(id: string): Promise<void> {
|
||||
try {
|
||||
await this.request(`/notificacoes/${id}/read`, { method: 'PUT' });
|
||||
} catch {
|
||||
const notifs = await this.getNotificacoes();
|
||||
const index = notifs.findIndex(n => n.id === id);
|
||||
if (index >= 0) {
|
||||
notifs[index].lida = true;
|
||||
localStorage.setItem(this.KEYS.NOTIFICACOES, JSON.stringify(notifs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AUTH ---
|
||||
static isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem(this.KEYS.AUTH);
|
||||
}
|
||||
|
||||
static login(token: string) {
|
||||
localStorage.setItem(this.KEYS.AUTH, token);
|
||||
}
|
||||
|
||||
static logout() {
|
||||
localStorage.removeItem(this.KEYS.AUTH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MedNotificacao, MedAgendamento, MedFinanceiro } from '../types.ts';
|
||||
import { MedicoBackend } from './medicoBackend.ts';
|
||||
|
||||
/**
|
||||
* Intelligent Notification Engine for Medical Module
|
||||
*/
|
||||
export class MedicoNotif {
|
||||
static async generateIntelligentAlerts(): Promise<void> {
|
||||
const agendamentos = await MedicoBackend.getAgendamentos();
|
||||
const financeiro = await MedicoBackend.getFinanceiro();
|
||||
const existingNotifs = await MedicoBackend.getNotificacoes();
|
||||
|
||||
const newNotifs: MedNotificacao[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// 1. Check for upcoming appointments (next 24h)
|
||||
agendamentos.forEach(ag => {
|
||||
const agDate = new Date(ag.start);
|
||||
const diffHours = (agDate.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours > 0 && diffHours < 24 && ag.status === 'Agendado') {
|
||||
const title = 'Confirmação de Agenda';
|
||||
const msg = `Paciente ${ag.pacienteNome} tem agendamento amanhã às ${agDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
if (!this.exists(existingNotifs, title, msg)) {
|
||||
newNotifs.push(this.create(title, msg, 'agenda', 'medium'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check for overdue payments
|
||||
financeiro.forEach(fin => {
|
||||
const venc = new Date(fin.dataVencimento);
|
||||
if (fin.status === 'Pendente' && venc < now) {
|
||||
const title = 'Pagamento em Atraso';
|
||||
const msg = `Mensalidade do paciente com ID ${fin.pacienteId} está atrasada desde ${venc.toLocaleDateString()}.`;
|
||||
|
||||
if (!this.exists(existingNotifs, title, msg)) {
|
||||
newNotifs.push(this.create(title, msg, 'financeiro', 'high'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newNotifs.length > 0) {
|
||||
const allNotifs = [...newNotifs, ...existingNotifs];
|
||||
localStorage.setItem('med_notificacoes', JSON.stringify(allNotifs));
|
||||
}
|
||||
}
|
||||
|
||||
private static create(titulo: string, mensagem: string, tipo: MedNotificacao['tipo'], priority: MedNotificacao['priority']): MedNotificacao {
|
||||
return {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
titulo,
|
||||
mensagem,
|
||||
tipo,
|
||||
priority,
|
||||
data: new Date().toISOString(),
|
||||
lida: false
|
||||
};
|
||||
}
|
||||
|
||||
private static exists(list: MedNotificacao[], title: string, msg: string): boolean {
|
||||
return list.some(n => n.titulo === title && n.mensagem === msg);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* MEDICAL MODULE TYPES
|
||||
* Standalone engine for the Medical CRM + Loyalty system
|
||||
*/
|
||||
|
||||
export interface MedPaciente {
|
||||
id: string;
|
||||
nome: string;
|
||||
cpf: string;
|
||||
telefone: string;
|
||||
email: string;
|
||||
dataNascimento: string;
|
||||
planoTipo: 'Base' | 'Premium'; // Médico + Odonto vs Médico + Odonto + Psico
|
||||
statusFidelidade: 'Ativo' | 'Inativo' | 'Inadimplente';
|
||||
ultimoAtendimento?: string;
|
||||
}
|
||||
|
||||
export interface MedProfissional {
|
||||
id: string;
|
||||
nome: string;
|
||||
crm: string;
|
||||
especialidade: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
ativo: boolean;
|
||||
horarios?: string;
|
||||
corAgenda: string;
|
||||
}
|
||||
|
||||
export interface MedAgendamento {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
pacienteNome: string;
|
||||
profissionalId: string;
|
||||
start: string; // ISO String
|
||||
end: string; // ISO String
|
||||
status: 'Agendado' | 'Confirmado' | 'Em Atendimento' | 'Finalizado' | 'Cancelado' | 'Falta';
|
||||
tipo: 'Consulta' | 'Retorno' | 'Exame' | 'Procedimento';
|
||||
observacoes?: string;
|
||||
cor?: string;
|
||||
}
|
||||
|
||||
export interface MedProntuario {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
data: string;
|
||||
profissionalId: string;
|
||||
anamnese: string;
|
||||
prescricao?: string;
|
||||
examesSolicitados?: string[];
|
||||
cid?: string[];
|
||||
anexos?: string[];
|
||||
}
|
||||
|
||||
export interface MedFinanceiro {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
descricao: string;
|
||||
valor: number;
|
||||
dataVencimento: string;
|
||||
dataPagamento?: string;
|
||||
status: 'Pendente' | 'Pago' | 'Atrasado';
|
||||
metodo: 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro';
|
||||
recorrencia: boolean; // Para o motor de fidelidade
|
||||
}
|
||||
|
||||
export interface MedFidelidadePlano {
|
||||
id: string;
|
||||
nome: string;
|
||||
valorMensal: number;
|
||||
inclusoOdonto: boolean; // Sempre true na base
|
||||
inclusoPsicologia: boolean; // True apenas no premium
|
||||
limiteConsultasMes: number;
|
||||
descontoMedicamentos: number;
|
||||
}
|
||||
|
||||
export interface MedNotificacao {
|
||||
id: string;
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
tipo: 'alerta' | 'financeiro' | 'agenda' | 'lead';
|
||||
data: string;
|
||||
lida: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface MedConfig {
|
||||
apiKey?: string;
|
||||
hospitalName: string;
|
||||
unidadeId: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
}
|
||||
});
|
||||
Generated
+595
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"axios": "^1.13.5",
|
||||
"mysql2": "^3.17.4",
|
||||
"playwright": "^1.58.2",
|
||||
"qs": "^6.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.17.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.17.4.tgz",
|
||||
"integrity": "sha512-RnfuK5tyIuaiPMWOCTTl4vQX/mQXqSA8eoIbwvWccadvPGvh+BYWWVecInMS5s7wcLUkze8LqJzwB/+A4uwuAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "cassems-automation",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"axios": "^1.13.5",
|
||||
"mysql2": "^3.17.4",
|
||||
"playwright": "^1.58.2",
|
||||
"qs": "^6.15.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login para exploração...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login realizado. Explorando menu...');
|
||||
|
||||
// Esperar o menu carregar
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Listar menus principais
|
||||
const menuNames = ['Tratamento Odontológico', 'Elegibilidade', 'Faturamento', 'Solicitações Diversas'];
|
||||
|
||||
for (const name of menuNames) {
|
||||
console.log(`Clicando no menu: ${name}`);
|
||||
try {
|
||||
await page.click(`text="${name}"`, { timeout: 2000 });
|
||||
await page.waitForTimeout(1000);
|
||||
} catch (e) {
|
||||
console.log(`Erro ao clicar em ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tentar clicar em Solicitações Diversas -> Inclusão
|
||||
console.log('Navegando em Solicitações Diversas -> Inclusão...');
|
||||
try {
|
||||
await page.click('text="Solicitações Diversas"');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('text="Inclusão"');
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('URL após clicar em Inclusão:', page.url());
|
||||
} catch (e) {
|
||||
console.log('Falha ao seguir caminho Inclusão:', e.message);
|
||||
}
|
||||
|
||||
// Listar links com palavras-chave
|
||||
const guiaLinks = await page.evaluate(() => {
|
||||
const keywords = ['guia', 'sadt', 'consulta', 'externa', 'recebimentos', 'faturamento', 'lote'];
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim(),
|
||||
href: a.getAttribute('href')
|
||||
})).filter(l => keywords.some(k => l.text.toLowerCase().includes(k)) || keywords.some(k => (l.href || '').toLowerCase().includes(k)));
|
||||
});
|
||||
|
||||
console.log('Links relacionados a guias encontrados:', JSON.stringify(guiaLinks, null, 2));
|
||||
|
||||
// Listar TODOS os links do DOM para ver os caminhos
|
||||
const allLinks = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim().replace(/\n/g, ' '),
|
||||
href: a.getAttribute('href'),
|
||||
visible: a.offsetParent !== null
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('--- TODOS OS LINKS ENCONTRADOS (VISÍVEIS E INVISÍVEIS) ---');
|
||||
allLinks.forEach(l => {
|
||||
if (l.text && !l.href.includes('void(0)') && !l.href.startsWith('#')) {
|
||||
console.log(`[${l.visible ? 'VISÍVEL' : 'OCULTO'}] ${l.text} -> ${l.href}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Tirar um "print" da estrutura do menu principal
|
||||
const menuStructure = await page.evaluate(() => {
|
||||
// Tenta encontrar o container principal do menu (geralmente nav ou ul com classes específicas)
|
||||
const nav = document.querySelector('nav, ul.navbar-nav, .sidebar, #menu');
|
||||
return nav ? nav.innerText : 'Container de menu não identificado diretamente';
|
||||
});
|
||||
console.log('Estrutura de texto do menu:', menuStructure);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro na exploração:', error);
|
||||
} finally {
|
||||
console.log('Mantendo aberto para inspeção manual por 60 segundos...');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login OK. Navegando para Novo Registro (Guia)...');
|
||||
|
||||
// Tentar ir direto para a URL de novo registro
|
||||
const baseUrl = 'https://viventerisportalcredenciado.topsaudehub.com.br';
|
||||
await page.goto(baseUrl + '/HomePortalCredenciado/WorkFlow/NovoRegistro', { waitUntil: 'networkidle' });
|
||||
|
||||
console.log('Página de Novo Registro carregada. Analisando campos...');
|
||||
await page.waitForTimeout(3000); // Esperar carregar formulário dinâmico
|
||||
|
||||
// Listar todos os inputs, selects e botões para entender o formulário de guias
|
||||
const formFields = await page.evaluate(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input, select, textarea, button'));
|
||||
return inputs.map(i => ({
|
||||
tagName: i.tagName,
|
||||
type: i.type,
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
placeholder: i.placeholder,
|
||||
labelText: i.labels?.[0]?.innerText || i.parentElement?.innerText?.split('\n')[0] || ''
|
||||
})).filter(i => i.id || i.name);
|
||||
});
|
||||
|
||||
console.log('CAMPOS DO FORMULÁRIO ENCONTRADOS:', JSON.stringify(formFields, null, 2));
|
||||
|
||||
// Verificar se há algum seletor de "Tipo de Guia"
|
||||
const tiposGuia = await page.evaluate(() => {
|
||||
const select = document.querySelector('select');
|
||||
if (select) {
|
||||
return Array.from(select.options).map(o => o.text);
|
||||
}
|
||||
return 'Nenhum select encontrado';
|
||||
});
|
||||
console.log('Opções de Guia/Select:', tiposGuia);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro:', error);
|
||||
} finally {
|
||||
console.log('Mantendo aberto para sua visualização por 60 segundos...');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,221 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('🚀 Iniciando Automação de Guia Odontológica...');
|
||||
|
||||
// LOGIN
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
|
||||
// 1. Tratamento Odontológico
|
||||
console.log('1. Acessando Tratamento Odontológico...');
|
||||
await page.click('text="Tratamento Odontológico"');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 2. Lupa de pesquisa
|
||||
console.log('2. Clicando na pesquisa (lupa)...');
|
||||
// Tentativa de achar o botão de busca que abre o modal de beneficiário
|
||||
const searchBtn = page.locator('.fa-search, .glyphicon-search, button[title*="Pesquisar"]').first();
|
||||
await searchBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. CPF do Beneficiário
|
||||
console.log('3. Inserindo CPF: 005.983.051-49...');
|
||||
// Esperar o campo ficar visível no modal
|
||||
const cpfInput = page.locator('input[id*="cpf"], input[name*="cpf"], .cpf-mask').first();
|
||||
await cpfInput.fill('005.983.051-49');
|
||||
|
||||
// 4. Pesquisar
|
||||
console.log('4. Clicando em Pesquisar...');
|
||||
await page.click('button:has-text("Pesquisar"), #btnPesquisarBeneficiario');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 5. Clica na coluna número beneficiário
|
||||
console.log('5. Selecionando beneficiário resultado...');
|
||||
// Espera especificamente por um link que pareça um número de cartão ou nome
|
||||
const linkBeneficiario = page.locator('table tbody tr td a').first();
|
||||
await linkBeneficiario.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await linkBeneficiario.click();
|
||||
await page.waitForTimeout(2000); // Espera carregar os dados dele
|
||||
|
||||
// 6. Botão +
|
||||
console.log('6. Clicando em Adicionar (+)...');
|
||||
// O botão + muitas vezes só aparece após selecionar o beneficiário
|
||||
const btnMais = page.locator('.fa-plus, .btn-success, #btnNovoRegistro, a:has-text("Novo"), button:has-text("+")').first();
|
||||
await btnMais.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btnMais.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 7. Dropdown tipo atendimento
|
||||
console.log('7. Selecionando Tipo de Atendimento...');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verifica se existe um select de endereço primeiro
|
||||
const enderecoSelect = page.locator('select[id*="endereco"], select[name*="endereco"]').first();
|
||||
if (await enderecoSelect.count() > 0) {
|
||||
console.log('Selecionando endereço de atendimento...');
|
||||
await enderecoSelect.selectOption({ index: 1 });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Tenta encontrar o select de tipo de atendimento especificamente
|
||||
const selectTipo = page.locator('select[id*="tipo_solicitacao"], select[id*="tipoAtendimento"], select:has-text("Tratamento")').first();
|
||||
await selectTipo.waitFor({ state: 'visible', timeout: 5000 }).catch(() => console.log('Aviso: Select de tipo não visível ainda.'));
|
||||
|
||||
const options = await selectTipo.evaluate(sel => Array.from(sel.options).map(o => o.text)).catch(() => []);
|
||||
console.log('Opções no select de tipo:', options);
|
||||
|
||||
try {
|
||||
await selectTipo.selectOption({ label: /Tratamento Odontológico/i });
|
||||
} catch (e) {
|
||||
console.log('Tentando selecionar pelo índice 1...');
|
||||
await selectTipo.selectOption({ index: 1 });
|
||||
}
|
||||
|
||||
// 8 e 9. Lupa Profissional Executante
|
||||
console.log('8. Selecionando Profissional Executante...');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Agora temos o ID exato do botão!
|
||||
const btnBuscaProf = page.locator('#btnBuscaPrestadorExecutante').first();
|
||||
|
||||
await btnBuscaProf.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btnBuscaProf.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Clica no profissional na tabela
|
||||
console.log('Selecionando o primeiro profissional da lista...');
|
||||
const linkProf = page.locator('table tbody tr td a').first();
|
||||
if (await linkProf.count() > 0) {
|
||||
await linkProf.click();
|
||||
} else {
|
||||
console.log('⚠️ Nenhum profissional encontrado na tabela. Aguardando instrução.');
|
||||
return; // PARA e aguarda
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 10. Profissional Solicitante
|
||||
console.log('10. Preenchendo Solicitante: Murilo...');
|
||||
const solicitanteInput = page.locator('input[id*="solicitante"], input[name*="Solicitante"]').first();
|
||||
if (await solicitanteInput.count() === 0) {
|
||||
console.log('⚠️ Campo "Profissional Solicitante" não encontrado. Aguardando instrução.');
|
||||
return;
|
||||
}
|
||||
await solicitanteInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await solicitanteInput.clear();
|
||||
await solicitanteInput.fill('Murilo');
|
||||
|
||||
// 11. Justificativa (APENAS se for urgência - Valor 4)
|
||||
const tipoAtendimentoValue = await page.locator('#tipoAtendimento').inputValue();
|
||||
if (tipoAtendimentoValue === '4') {
|
||||
console.log('Detectado Urgência / Emergência. Preenchendo Justificativa...');
|
||||
await page.fill('#observacaoJustificativa', 'Paciente com dor aguda, necessidade de atendimento de urgência.');
|
||||
} else {
|
||||
console.log('Tipo de atendimento normal. Pulando justificativa.');
|
||||
}
|
||||
|
||||
// 12. Próximo
|
||||
console.log('12. Clicando em Próximo...');
|
||||
await page.click('button:has-text("Próximo"), #btnProximo');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 13. Incluir
|
||||
console.log('13. Clicando em Incluir...');
|
||||
await page.click('#adicionarProcedimeto');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// EXTRAÇÃO DE CÓDIGOS (Como solicitado pelo usuário na primeira vez)
|
||||
console.log('🔍 Extraindo lista de procedimentos disponíveis...');
|
||||
const procedimentos = await page.evaluate(() => {
|
||||
const items = Array.from(document.querySelectorAll('input[id*="procedimento"], select[id*="procedimento"], .lista-procedimentos td'));
|
||||
// Tenta pegar de um select se houver
|
||||
const sel = document.querySelector('select[id*="procedimento"]');
|
||||
if (sel) return Array.from(sel.options).map(o => `${o.value} - ${o.text}`);
|
||||
return items.map(i => i.innerText || i.value).filter(t => t && t.length > 5);
|
||||
});
|
||||
|
||||
if (procedimentos.length > 0) {
|
||||
fs.writeFileSync('procedimentos_encontrados.txt', procedimentos.join('\n'));
|
||||
console.log('✅ Lista de procedimentos salva em procedimentos_encontrados.txt');
|
||||
}
|
||||
|
||||
// 15, 16 e 17. Dados do Tratamento
|
||||
console.log('15. Lançando procedimento 85100196...');
|
||||
const procInput = page.locator('#codProcedimento').first();
|
||||
await procInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await procInput.fill('85100196');
|
||||
|
||||
// Espera e clica no item do autocomplete
|
||||
console.log('Selecionando item no autocomplete...');
|
||||
const autocompleteItem = page.locator('.ui-menu-item-wrapper:has-text("85100196")').first();
|
||||
await autocompleteItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await autocompleteItem.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('16. Selecionando Dente 18...');
|
||||
const selectDente = page.locator('#cmbDente').first();
|
||||
await selectDente.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await selectDente.selectOption('18');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Selecionar a checkbox da Face "V"
|
||||
console.log('17. Selecionando Face V...');
|
||||
const checkV = page.locator('#codFaceV').first();
|
||||
await checkV.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Tenta clicar forçado no input ou clica na label (que é o que o usuário vê)
|
||||
try {
|
||||
await checkV.check({ force: true });
|
||||
} catch (e) {
|
||||
await page.click('label[for="codFaceV"]');
|
||||
}
|
||||
|
||||
// 18. Incluir procedimento (Botão Incluir dentro do modal)
|
||||
console.log('18. Incluindo procedimento na lista...');
|
||||
await page.click('#btnIncluirProcedimento');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Se houver um botão de fechar/salvar o popup geral (como o btnSalvarPopUP que vimos antes)
|
||||
const btnFechar = page.locator('#btnSalvarPopUP').first();
|
||||
if (await btnFechar.isVisible()) {
|
||||
console.log('Fechando popup de procedimentos...');
|
||||
await btnFechar.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 19. Finalizar
|
||||
console.log('19. Finalizando guia...');
|
||||
await page.click('button:has-text("Próximo")');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.click('button:has-text("Próximo"), #btnFinalizar');
|
||||
|
||||
// 20. Captura de GTO
|
||||
console.log('20. Capturando resultado da GTO...');
|
||||
await page.waitForTimeout(3000);
|
||||
const resultadoHTML = await page.content();
|
||||
const gtoMatch = resultadoHTML.match(/\d{8,12}/); // Tenta achar um número longo que seria a GTO
|
||||
const status = resultadoHTML.includes('Autorizado') ? 'AUTORIZADO' : 'VERIFICAR STATUS';
|
||||
|
||||
console.log(`--- RESULTADO --- GTO: ${gtoMatch ? gtoMatch[0] : 'Não localizada'} | STATUS: ${status}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na automação:', error.message);
|
||||
await page.screenshot({ path: 'erro-guia-odonto.png' });
|
||||
} finally {
|
||||
console.log('Processo interrompido para análise. O navegador ficará aberto por 60s.');
|
||||
await page.waitForTimeout(60000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
// Configuração para evitar mensagem de navegador incompatível
|
||||
const browser = await chromium.launch({
|
||||
headless: false // Vamos deixar visível para você ver acontecendo
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Navegando para o portal...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
|
||||
console.log('Preenchendo credenciais...');
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
|
||||
console.log('Clicando em ENTRAR...');
|
||||
await page.click('#login-submit');
|
||||
|
||||
// Espera um pouco para ver o resultado (sucesso ou erro)
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log('URL atual após login:', currentUrl);
|
||||
|
||||
if (currentUrl.includes('AreaLogada')) {
|
||||
console.log('✅ LOGIN REALIZADO COM SUCESSO!');
|
||||
} else {
|
||||
// Tenta verificar erro apenas se não estiver na área logada
|
||||
const errorMsg = await page.locator('.msg:visible').textContent({ timeout: 5000 }).catch(() => null);
|
||||
if (errorMsg) {
|
||||
console.log('❌ Falha no login. Mensagem do sistema:', errorMsg.trim());
|
||||
} else {
|
||||
console.log('Status do login incerto. Verifique a janela do navegador.');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ocorreu um erro durante a automação:', error);
|
||||
} finally {
|
||||
// Mantemos o navegador aberto por 20 segundos para você ver o resultado
|
||||
console.log('Mantendo o navegador aberto por 20 segundos...');
|
||||
await page.waitForTimeout(20000);
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
console.log('Realizando login...');
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'networkidle' });
|
||||
await page.fill('#usuario', '0005010');
|
||||
await page.fill('#senha', 'Cclinic#03');
|
||||
await page.click('#login-submit');
|
||||
|
||||
await page.waitForURL('**/AreaLogada**');
|
||||
console.log('Login OK. Varrendo todos os links e data-href...');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const links = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(a => ({
|
||||
text: a.innerText.trim(),
|
||||
href: a.getAttribute('href'),
|
||||
dataHref: a.getAttribute('data-href'),
|
||||
id: a.id
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('LISTA COMPLETA DE LINKS:');
|
||||
links.forEach(l => {
|
||||
if (l.text || l.dataHref) {
|
||||
console.log(`- TEXTO: "${l.text}" | HREF: "${l.href}" | DATA-HREF: "${l.dataHref}" | ID: "${l.id}"`);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro:', error);
|
||||
} finally {
|
||||
console.log('Fim da exploração.');
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
console.log('🔍 Iniciando mapeamento de API para Axios...');
|
||||
const browser = await chromium.launch({ headless: false }); // Usar Chrome visível para evitar bloqueio
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Interceptar todas as requisições para descobrir os Endpoints
|
||||
page.on('request', request => {
|
||||
if (request.method() === 'POST' && !request.url().includes('google-analytics')) {
|
||||
console.log(`\n🚀 POST Detectado: ${request.url()}`);
|
||||
console.log('Headers:', JSON.stringify(request.headers(), null, 2));
|
||||
console.log('Payload:', request.postData());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto('https://viventerisportalcredenciado.topsaudehub.com.br/', { waitUntil: 'load', timeout: 60000 });
|
||||
|
||||
console.log('Esperando formulário de login...');
|
||||
const userField = page.locator('#usuario, #Username').first();
|
||||
await userField.waitFor({ timeout: 30000 });
|
||||
|
||||
console.log('Preenchendo credenciais...');
|
||||
await userField.fill('0005010');
|
||||
const passField = page.locator('#senha, #Password').first();
|
||||
await passField.fill('Cclinic#03');
|
||||
|
||||
console.log('Clicando em Login...');
|
||||
const loginBtn = page.locator('#login-submit, #btnLogin').first();
|
||||
await loginBtn.click();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Navegando pelo menu...');
|
||||
await page.click('text="Tratamento Odontológico"');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Abrindo busca de beneficiário...');
|
||||
const searchBtn = page.locator('.fa-search, .glyphicon-search, button[title*="Pesquisar"]').first();
|
||||
await searchBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Inserindo CPF...');
|
||||
const cpfInput = page.locator('input[id*="cpf"], input[name*="cpf"], .cpf-mask').first();
|
||||
await cpfInput.fill('005.983.051-49');
|
||||
await page.click('button:has-text("Pesquisar"), #btnPesquisarBeneficiario');
|
||||
|
||||
console.log('Aguardando resultados da busca...');
|
||||
const linkBeneficiario = page.locator('table tbody tr td a').first();
|
||||
try {
|
||||
await linkBeneficiario.waitFor({ state: 'visible', timeout: 15000 });
|
||||
console.log('Selecionando beneficiário...');
|
||||
await linkBeneficiario.click();
|
||||
} catch (e) {
|
||||
console.log('⚠️ Resultado da busca não apareceu ou demorou demais.');
|
||||
const content = await page.content();
|
||||
if (content.includes('nenhum registro encontrado') || content.includes('não encontrado')) {
|
||||
console.log('❌ Beneficiário não encontrado no portal.');
|
||||
} else {
|
||||
console.log('Logando HTML para análise de erro...');
|
||||
require('fs').writeFileSync('logs-and-data/debug-search-error.html', content);
|
||||
}
|
||||
throw new Error('Falha na busca de beneficiário durante o mapeamento.');
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Clicando em Adicionar (+)...');
|
||||
const btnMais = page.locator('.fa-plus, .btn-success, #btnNovoRegistro, button:has-text("+")').first();
|
||||
await btnMais.click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Preenchendo dados da guia...');
|
||||
const selectTipo = page.locator('select[id*="tipoAtendimento"]').first();
|
||||
await selectTipo.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await selectTipo.selectOption('1');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Buscando Profissional Executante...');
|
||||
const btnBuscaProf = page.locator('#btnBuscaPrestadorExecutante').first();
|
||||
await btnBuscaProf.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Selecionando primeiro profissional na tabela...');
|
||||
const linkProf = page.locator('table tbody tr td a').first();
|
||||
await linkProf.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await linkProf.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Verificando campo Solicitante...');
|
||||
const solicitanteInput = page.locator('input[id*="solicitante"], input[name*="Solicitante"]').first();
|
||||
|
||||
// Tenta esperar o campo, mas loga se falhar
|
||||
try {
|
||||
await solicitanteInput.waitFor({ state: 'visible', timeout: 15000 });
|
||||
console.log('Preenchendo Solicitante...');
|
||||
await solicitanteInput.fill('Murilo');
|
||||
} catch (e) {
|
||||
console.log('⚠️ Campo Solicitante não apareceu. Continuando para tentar capturar outros POSTs...');
|
||||
}
|
||||
|
||||
console.log('Avançando para procedimentos...');
|
||||
const btnProx = page.locator('button:has-text("Próximo"), #btnProximo').first();
|
||||
await btnProx.click();
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
console.log('Abrindo inclusão de procedimento...');
|
||||
await page.click('#adicionarProcedimeto');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Lançando procedimento para capturar JSON...');
|
||||
await page.fill('#codProcedimento', '85100196');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('.ui-menu-item-wrapper:has-text("85100196")');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.selectOption('#cmbDente', '11');
|
||||
await page.click('label[for="codFaceV"]');
|
||||
|
||||
console.log('Incluindo no modal (Captura JSON parcial)...');
|
||||
await page.click('#btnIncluirProcedimento');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('FINALIZANDO GUIA (Captura do POST Final)...');
|
||||
await page.click('button:has-text("Próximo")');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('button:has-text("Próximo"), #btnFinalizar');
|
||||
|
||||
await page.waitForTimeout(10000);
|
||||
console.log('\n✅ Mapeamento concluído com sucesso.');
|
||||
} catch (error) {
|
||||
console.error('Erro no mapeamento:', error);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
# BACKEND CONFIG
|
||||
PORT=3002
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=sis_odonto
|
||||
|
||||
# FRONTEND CONFIG (For local dev, usually not needed if using proxy/relative)
|
||||
# VITE_API_URL=http://localhost:3002/api
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,22 @@
|
||||
<IfModule mod_mime.c>
|
||||
# Força o servidor a enviar arquivos TSX/TS como texto javascript
|
||||
# Isso corrige o erro "MIME type of application/octet-stream"
|
||||
AddType text/javascript .js
|
||||
AddType text/javascript .jsx
|
||||
AddType text/javascript .ts
|
||||
AddType text/javascript .tsx
|
||||
AddType text/css .css
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /odonto/
|
||||
|
||||
# Se o arquivo ou diretório existir, serve ele normalmente
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Caso contrário, redireciona tudo para index.html (Fallback para SPA)
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HybridBackend } from './services/backend.ts';
|
||||
|
||||
import { Sidebar } from './components/Sidebar.tsx';
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
import { LeadsView } from './views/LeadsView.tsx';
|
||||
import { PublicContactForm } from './views/PublicContactForm.tsx';
|
||||
import { PatientsView } from './views/PatientsView.tsx';
|
||||
import { AgendaView } from './views/AgendaView.tsx';
|
||||
import { OrthoView } from './views/OrthoView.tsx';
|
||||
import { FinanceiroView } from './views/FinanceiroView.tsx';
|
||||
import { MeusTratamentos } from './views/MeusTratamentos';
|
||||
import { LancarGTO } from './views/LancarGTO';
|
||||
import { LoginView } from './views/LoginView.tsx';
|
||||
import { LandingPage } from './views/LandingPage.tsx';
|
||||
import { UpdateDbView } from './views/UpdateDbView.tsx';
|
||||
import { ClinicasView } from './views/ClinicasView.tsx';
|
||||
import { DentistRegisterView } from './views/DentistRegisterView.tsx';
|
||||
import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosView } from './views/AdminViews.tsx';
|
||||
import { NotificationsView } from './views/NotificationsView.tsx';
|
||||
import { ReportsView } from './views/ReportsView.tsx';
|
||||
|
||||
export type ViewKey =
|
||||
| 'landing'
|
||||
| 'dashboard'
|
||||
| 'leads'
|
||||
| 'public'
|
||||
| 'pacientes'
|
||||
| 'agenda'
|
||||
| 'ortodontia'
|
||||
| 'financeiro'
|
||||
| 'login'
|
||||
| 'update'
|
||||
| 'dentistas'
|
||||
| 'especialidades'
|
||||
| 'procedimentos'
|
||||
| 'planos'
|
||||
| 'tratamentos'
|
||||
| 'lancar-gto'
|
||||
| 'notificacoes'
|
||||
| 'clinicas'
|
||||
| 'sync'
|
||||
| 'relatorios'
|
||||
| 'cadastro-dentista';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/': 'landing',
|
||||
'/dashboard': 'dashboard',
|
||||
'/landing': 'landing',
|
||||
'/leads': 'leads',
|
||||
'/contato': 'public',
|
||||
'/pacientes': 'pacientes',
|
||||
'/agenda': 'agenda',
|
||||
'/ortodontia': 'ortodontia',
|
||||
'/financeiro': 'financeiro',
|
||||
'/login': 'login',
|
||||
'/update': 'update',
|
||||
'/clinicas': 'clinicas',
|
||||
'/admin/dentistas': 'dentistas',
|
||||
'/admin/especialidades': 'especialidades',
|
||||
'/admin/procedimentos': 'procedimentos',
|
||||
'/admin/planos': 'planos',
|
||||
'/admin/sync': 'sync',
|
||||
'/tratamentos': 'tratamentos',
|
||||
'/lancar-gto': 'lancar-gto',
|
||||
'/notificacoes': 'notificacoes',
|
||||
'/relatorios': 'relatorios',
|
||||
'/cadastro-dentista': 'cadastro-dentista',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
landing: '/',
|
||||
dashboard: '/dashboard',
|
||||
leads: '/leads',
|
||||
public: '/contato',
|
||||
pacientes: '/pacientes',
|
||||
agenda: '/agenda',
|
||||
ortodontia: '/ortodontia',
|
||||
financeiro: '/financeiro',
|
||||
login: '/login',
|
||||
update: '/update',
|
||||
clinicas: '/clinicas',
|
||||
dentistas: '/admin/dentistas',
|
||||
especialidades: '/admin/especialidades',
|
||||
procedimentos: '/admin/procedimentos',
|
||||
planos: '/admin/planos',
|
||||
tratamentos: '/tratamentos',
|
||||
'lancar-gto': '/lancar-gto',
|
||||
notificacoes: '/notificacoes',
|
||||
relatorios: '/relatorios',
|
||||
sync: '/admin/sync',
|
||||
'cadastro-dentista': '/cadastro-dentista'
|
||||
};
|
||||
|
||||
/** Get the hash path, e.g. "/#/pacientes" → "/pacientes" */
|
||||
function getHashPath(): string {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/')) return hash.slice(1); // "#/pacientes" → "/pacientes"
|
||||
if (hash === '#' || hash === '') return '/';
|
||||
return '/';
|
||||
}
|
||||
|
||||
/** Resolve current URL to a ViewKey */
|
||||
function resolveViewFromUrl(): ViewKey {
|
||||
// Legacy support: ?p=contato
|
||||
if (window.location.search.includes('p=contato')) return 'public';
|
||||
// Legacy support: #update_db (old non-slash hash)
|
||||
if (window.location.hash === '#update_db') return 'update';
|
||||
|
||||
const path = getHashPath();
|
||||
return ROUTE_MAP[path] ?? 'dashboard';
|
||||
}
|
||||
|
||||
/** Push a new hash-based URL without reloading the page */
|
||||
function navigate(view: ViewKey) {
|
||||
const route = VIEW_TO_ROUTE[view];
|
||||
window.location.hash = route;
|
||||
}
|
||||
|
||||
// ------- App -------
|
||||
const App: React.FC = () => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const clinColor = activeWorkspace?.cor || '#2563eb';
|
||||
|
||||
const isViewAllowed = (view: ViewKey, role: string): boolean => {
|
||||
if (['landing', 'login', 'public', 'update'].includes(view)) return true;
|
||||
if (role === 'admin' || role === 'donoclinica') return true;
|
||||
|
||||
const permissions: Record<string, ViewKey[]> = {
|
||||
paciente: ['tratamentos', 'notificacoes'],
|
||||
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas'],
|
||||
};
|
||||
|
||||
return permissions[role]?.includes(view) || false;
|
||||
};
|
||||
|
||||
const getDefaultViewForRole = (role: string): ViewKey => {
|
||||
if (role === 'paciente') return 'tratamentos';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const getInitialView = (): ViewKey => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
|
||||
// If user is authenticated, and trying to go to landing or login, send to default for role
|
||||
if (HybridBackend.isAuthenticated() && ['landing', 'login'].includes(fromUrl)) return getDefaultViewForRole(role);
|
||||
|
||||
// If URL points to a protected view but user is not auth'd → landing (or login)
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) return 'landing';
|
||||
|
||||
// Verify permission for the view
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
return getDefaultViewForRole(role);
|
||||
}
|
||||
|
||||
return fromUrl;
|
||||
};
|
||||
|
||||
const [currentView, setCurrentView] = useState<ViewKey>(getInitialView);
|
||||
|
||||
// Keep URL in sync when view changes programmatically via sidebar
|
||||
const handleNavigate = (view: ViewKey) => {
|
||||
if (isViewAllowed(view, currentRole)) {
|
||||
setCurrentView(view);
|
||||
navigate(view);
|
||||
setSidebarOpen(false); // Close sidebar on mobile after navigation
|
||||
}
|
||||
};
|
||||
|
||||
// Sync view when user uses browser back/forward buttons
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) {
|
||||
setCurrentView('landing');
|
||||
navigate('landing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
const defView = getDefaultViewForRole(role);
|
||||
setCurrentView(defView);
|
||||
navigate(defView);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentView(fromUrl);
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
// Inject dynamic brand color
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--brand-color', clinColor);
|
||||
document.documentElement.style.setProperty('--brand-color-soft', `${clinColor}11`);
|
||||
}, [clinColor]);
|
||||
|
||||
// On mount: if there's no hash yet, write the current view into the URL
|
||||
useEffect(() => {
|
||||
if (!window.location.hash || window.location.hash === '#update_db') {
|
||||
navigate(currentView);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'landing': return <LandingPage onGetStarted={() => handleNavigate('login')} />;
|
||||
case 'dashboard': return <Dashboard />;
|
||||
case 'leads': return <LeadsView />;
|
||||
case 'public': return <PublicContactForm />;
|
||||
case 'pacientes': return <PatientsView />;
|
||||
case 'agenda': return <AgendaView />;
|
||||
case 'ortodontia': return <OrthoView />;
|
||||
case 'financeiro': return <FinanceiroView />;
|
||||
case 'dentistas': return <DentistasView />;
|
||||
case 'especialidades': return <EspecialidadesView />;
|
||||
case 'procedimentos': return <ProcedimentosView />;
|
||||
case 'planos': return <PlanosView />;
|
||||
case 'sync': return <SyncView />;
|
||||
case 'tratamentos': return <MeusTratamentos />;
|
||||
case 'lancar-gto': return <LancarGTO />;
|
||||
case 'update': return <UpdateDbView />;
|
||||
case 'clinicas': return <ClinicasView />;
|
||||
case 'notificacoes': return <NotificationsView />;
|
||||
case 'relatorios': return <ReportsView />;
|
||||
case 'cadastro-dentista': return <DentistRegisterView />;
|
||||
case 'login':
|
||||
default:
|
||||
return <LoginView onLoginSuccess={() => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
handleNavigate(getDefaultViewForRole(role));
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista'].includes(currentView);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
|
||||
<style>{`
|
||||
:root { --brand-color: ${clinColor}; }
|
||||
.bg-brand { background-color: var(--brand-color); }
|
||||
.text-brand { color: var(--brand-color); }
|
||||
.border-brand { border-color: var(--brand-color); }
|
||||
.hover\\:bg-brand:hover { background-color: var(--brand-color); }
|
||||
.focus\\:ring-brand:focus { --tw-ring-color: var(--brand-color); }
|
||||
`}</style>
|
||||
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Hamburger Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 md:hidden backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Sidebar with mobile toggle logic */}
|
||||
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<Sidebar
|
||||
activeTab={currentView}
|
||||
setActiveTab={(t) => handleNavigate(t as ViewKey)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Navbar Header */}
|
||||
<div className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm shadow-sm" style={{ backgroundColor: clinColor }}>
|
||||
S
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
|
||||
</div>
|
||||
<div className="w-8"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 pb-20 scroll-smooth">
|
||||
<div className="max-w-7xl mx-auto h-full">
|
||||
{renderView()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1R1UvfhQooJcPo447sPMR5YdggjdMblPP
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@@ -0,0 +1,42 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkData() {
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
};
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
console.log('Connected to MySQL');
|
||||
|
||||
const [dentists] = await connection.query('SELECT * FROM dentistas');
|
||||
console.log('\n--- DENTISTAS ---');
|
||||
console.table(dentists.map(d => ({ id: d.id, nome: d.nome })));
|
||||
|
||||
const [agendamentos] = await connection.query('SELECT * FROM agendamentos');
|
||||
console.log('\n--- AGENDAMENTOS (Counts per Dentist) ---');
|
||||
const counts = {};
|
||||
agendamentos.forEach(a => {
|
||||
counts[a.dentistaId] = (counts[a.dentistaId] || 0) + 1;
|
||||
});
|
||||
|
||||
const countsTable = Object.entries(counts).map(([id, count]) => {
|
||||
const d = dentists.find(dentist => dentist.id === id);
|
||||
return {
|
||||
dentistId: id,
|
||||
nome: d ? d.nome : 'UNKNOWN (Maybe deleted or not assigned)',
|
||||
count: count
|
||||
};
|
||||
});
|
||||
console.table(countsTable);
|
||||
|
||||
await connection.end();
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
checkData();
|
||||
@@ -0,0 +1,25 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkData() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [dentists] = await connection.query('SELECT * FROM dentistas');
|
||||
const [agendamentos] = await connection.query('SELECT * FROM agendamentos');
|
||||
console.log('\n--- DENTISTAS ---');
|
||||
console.table(dentists.map(d => ({ id: d.id, nome: d.nome })));
|
||||
console.log('\n--- AGENDAMENTOS ---');
|
||||
console.table(agendamentos.map(a => {
|
||||
const d = dentists.find(dent => dent.id === a.dentistaId);
|
||||
return {
|
||||
id: a.id,
|
||||
paciente: a.pacienteNome,
|
||||
dentista: d ? d.nome : 'UNKNOWN',
|
||||
dentistaId: a.dentistaId,
|
||||
startTime: a.start_time
|
||||
};
|
||||
}));
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkData();
|
||||
@@ -0,0 +1,12 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkDentists() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.query('SELECT * FROM dentistas');
|
||||
console.table(rows);
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkDentists();
|
||||
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function checkSettings() {
|
||||
const dbConfig = { host: 'localhost', user: 'root', password: '', database: 'sis_odonto' };
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.query('SELECT * FROM settings WHERE id = "main"');
|
||||
if (rows[0]) {
|
||||
console.log('\n--- SETTINGS ---');
|
||||
console.log(JSON.stringify(JSON.parse(rows[0].data), null, 2));
|
||||
} else {
|
||||
console.log('No settings found.');
|
||||
}
|
||||
await connection.end();
|
||||
} catch (err) { console.error('Error:', err.message); }
|
||||
}
|
||||
checkSettings();
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Settings, Dentista } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
|
||||
import { Link as LinkIcon, Copy, Share2 } from 'lucide-react';
|
||||
|
||||
interface AgendaSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
dentists: Dentista[] | null;
|
||||
}
|
||||
|
||||
export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen, onClose, dentists }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [config, setConfig] = useState<Settings>({
|
||||
clinicEmail: '',
|
||||
clinicCalendarId: '',
|
||||
googleApiKey: '',
|
||||
googleCalendarIds: {}
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await HybridBackend.getSettings();
|
||||
setConfig({
|
||||
clinicEmail: settings.clinicEmail || '',
|
||||
clinicCalendarId: settings.clinicCalendarId || '',
|
||||
googleApiKey: settings.googleApiKey || '',
|
||||
googleCalendarIds: settings.googleCalendarIds || {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar configurações:", error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
fetchGoogleStatus();
|
||||
setPage(1); // Reset to first page
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const copyInviteLink = (dentistId: string) => {
|
||||
const url = `http://localhost:3005/api/auth/google/url?dentistId=${dentistId}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("LINK DE CONVITE COPIADO!");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await HybridBackend.saveSettings(config);
|
||||
toast.success("CONFIGURAÇÕES SALVAS COM SUCESSO!");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error("ERRO AO SALVAR CONFIGURAÇÕES.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const totalPages = 3;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-4">
|
||||
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-300 flex flex-col h-[550px] border border-gray-100">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-10 py-8 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-600 rounded-2xl shadow-lg shadow-blue-200">
|
||||
<GearIcon size={24} className="text-white animate-spin-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tighter">Configurações da Agenda</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Integrações e Comunicação</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1.5 w-full bg-gray-100 flex">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-500 ease-out shadow-[0_0_10px_rgba(37,99,235,0.5)]"
|
||||
style={{ width: `${(page / totalPages) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Horizontal Slider Simulation */}
|
||||
<div className="flex-1 relative overflow-hidden bg-white">
|
||||
<div
|
||||
className="absolute inset-0 flex transition-transform duration-500 ease-in-out transform-gpu"
|
||||
style={{ transform: `translateX(-${(page - 1) * 100}%)` }}
|
||||
>
|
||||
{/* Page 1: Clinic Emails */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Mail className="text-blue-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">E-mails da Clínica</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">E-mail para Notificações</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="EX: CONTATO@CLINICA.COM"
|
||||
className="w-full bg-gray-50 border-2 border-transparent focus:border-blue-600 focus:bg-white rounded-2xl p-4 font-bold text-gray-800 outline-none transition-all shadow-sm"
|
||||
value={config.clinicEmail}
|
||||
onChange={(e) => setConfig({ ...config, clinicEmail: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 2: Google Setup Tutorial & Central Config */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="text-emerald-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Configuração Google Agenda</h3>
|
||||
</div>
|
||||
<a href="https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-black px-3 py-1.5 rounded-lg hover:opacity-80 transition-all uppercase tracking-widest">
|
||||
1. Ativar API
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-5 h-5 bg-emerald-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">1</div>
|
||||
<span className="text-[10px] font-black text-emerald-800 uppercase">Google API Key</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="COLE A CHAVE AQUI"
|
||||
className="w-full bg-white border border-emerald-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-emerald-100"
|
||||
value={config.googleApiKey}
|
||||
onChange={(e) => setConfig({ ...config, googleApiKey: e.target.value })}
|
||||
/>
|
||||
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noreferrer" className="text-[8px] font-black text-emerald-600 hover:underline mt-2 inline-block uppercase">Criar Credenciais ➔</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-5 h-5 bg-blue-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">2</div>
|
||||
<span className="text-[10px] font-black text-blue-800 uppercase">ID Agenda Clínica</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID DA AGENDA"
|
||||
className="w-full bg-white border border-blue-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-100"
|
||||
value={config.clinicCalendarId}
|
||||
onChange={(e) => setConfig({ ...config, clinicCalendarId: e.target.value })}
|
||||
/>
|
||||
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[8px] font-black text-blue-600 hover:underline mt-2 inline-block uppercase">Pegar ID nas Configs ➔</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-3xl p-6 text-white">
|
||||
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-[0.2em] mb-4">Guia de Integração</h4>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">Crie um projeto no <strong>Google Cloud Console</strong>.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">No menu 'Biblioteca', ative a <strong>Google Calendar API</strong>.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-gray-300">Pegue o <strong>ID da Agenda</strong> nas configurações (Integrar agenda).</span>
|
||||
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-blue-400 hover:underline flex items-center gap-1 uppercase">
|
||||
Abrir Configurações de Agenda ➔
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3 text-xs">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
||||
<span className="text-gray-300">No Google Agenda, torne a agenda <strong>Pública</strong> nas autorizações de acesso.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 3: Google Calendar Mapping */}
|
||||
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="text-emerald-600" size={24} />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Agendas dos Dentistas</h3>
|
||||
</div>
|
||||
<a href="https://calendar.google.com/calendar/u/0/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-gray-900 px-3 py-1.5 rounded-lg hover:bg-black transition-colors uppercase tracking-widest">
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[250px] overflow-y-auto pr-4 custom-scrollbar">
|
||||
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 mb-2">
|
||||
<p className="text-[10px] text-blue-800 font-bold mb-1 uppercase tracking-wider">Nova Integração Inteligente (OAuth2)</p>
|
||||
<p className="text-[10px] text-blue-600 italic">Agora você pode conectar agendas sem precisar torná-las públicas ou usar API Keys.</p>
|
||||
</div>
|
||||
|
||||
{/* ADMIN / CLINIC CONNECTION */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Sua Conta (Administrador)</h4>
|
||||
<GoogleConnectButton
|
||||
ownerId="admin"
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id.includes('@'))}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Contas dos Dentistas</h4>
|
||||
{dentists?.length ? dentists.map((dentist) => {
|
||||
const isConnected = connectedAccounts.some(a => a.owner_id === dentist.id);
|
||||
return (
|
||||
<div key={dentist.id} className="bg-gray-50 rounded-2xl p-4 border border-transparent hover:border-gray-200 transition-all">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: dentist.corAgenda }}></div>
|
||||
<span className="text-xs font-black text-gray-800 uppercase">{dentist.nome}</span>
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<span className="text-[9px] font-black text-emerald-600 bg-emerald-100 px-2 py-1 rounded-lg uppercase">Conectado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black text-gray-400 uppercase">Não Vinculado</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<GoogleConnectButton
|
||||
ownerId={dentist.id}
|
||||
isConnected={isConnected}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<button
|
||||
onClick={() => copyInviteLink(dentist.id)}
|
||||
className="p-4 bg-gray-100 text-gray-600 rounded-2xl hover:bg-gray-200 transition-all flex items-center justify-center group"
|
||||
title="Gerar Link para o Dentista"
|
||||
>
|
||||
<Share2 size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legacy ID support remains for backward compatibility */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200/50">
|
||||
<label className="text-[9px] font-bold text-gray-400 uppercase ml-1">ID Alternativo (API Key/Legado)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID DA AGENDA GOOGLE (SE NÃO USAR OAUTH2)"
|
||||
className="w-full bg-white/50 border border-gray-200 rounded-xl px-4 py-2 text-[10px] font-bold text-gray-500 outline-none focus:ring-1 focus:ring-blue-100 mt-1"
|
||||
value={config.googleCalendarIds?.[dentist.id] || ''}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
googleCalendarIds: {
|
||||
...config.googleCalendarIds,
|
||||
[dentist.id]: e.target.value
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<p className="text-center text-xs text-gray-400 py-10 uppercase font-bold">Nenhum dentista cadastrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="flex items-center gap-2 px-6 py-3 text-xs font-black text-gray-500 hover:text-gray-900 bg-white rounded-2xl border border-gray-200 hover:shadow-md transition-all uppercase tracking-widest"
|
||||
>
|
||||
<ChevronLeft size={16} /> Anterior
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{page < totalPages ? (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="flex items-center gap-2 px-8 py-4 bg-blue-600 text-white rounded-[1.5rem] text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
||||
>
|
||||
Próximo <ChevronRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 px-10 py-4 bg-emerald-600 text-white rounded-[1.5rem] text-xs font-black uppercase tracking-widest hover:bg-emerald-700 hover:shadow-xl hover:shadow-emerald-200 transition-all shadow-lg shadow-emerald-100 disabled:bg-gray-400"
|
||||
>
|
||||
{isSaving ? 'Salvando...' : <><Save size={16} /> Salvar Tudo</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw, XCircle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
// FIX: Switched to constructor-based state initialization to resolve issues
|
||||
// where the TypeScript toolchain might not correctly interpret public class fields,
|
||||
// causing `this.props` and `this.setState` to appear unavailable. This is a more
|
||||
// widely compatible approach.
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-red-50 flex items-center justify-center p-6 font-sans">
|
||||
<div className="max-w-3xl w-full bg-white rounded-2xl shadow-2xl overflow-hidden border border-red-100">
|
||||
<div className="bg-red-600 p-6 flex items-center gap-4">
|
||||
<div className="bg-white/20 p-3 rounded-full text-white">
|
||||
<XCircle size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white uppercase">ERRO CRÍTICO NO SISTEMA</h1>
|
||||
<p className="text-red-100 text-xs font-bold uppercase mt-1">O APLICATIVO ENCONTROU UM PROBLEMA INESPERADO</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 font-bold uppercase mb-2">O QUE ACONTECEU?</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Ocorreu uma falha durante a renderização da interface. Tente recarregar a página.
|
||||
Se o problema persistir, envie o log abaixo para o suporte técnico.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="bg-gray-900 rounded-lg p-4 mb-6 overflow-x-auto border-l-4 border-red-500">
|
||||
<div className="flex items-center gap-2 text-red-400 font-bold text-xs uppercase mb-2">
|
||||
<AlertTriangle size={14} /> Stack Trace / Log Técnico
|
||||
</div>
|
||||
<pre className="text-green-400 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words">
|
||||
{this.state.error.toString()}
|
||||
<br />
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors shadow-lg"
|
||||
>
|
||||
<RefreshCw size={18} /> RECARREGAR SISTEMA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { localStorage.clear(); window.location.reload(); }}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors"
|
||||
>
|
||||
LIMPAR CACHE E REINICIAR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Link, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface GoogleConnectButtonProps {
|
||||
ownerId: string; // Ex: 'admin' ou o ID do dentista
|
||||
isConnected: boolean;
|
||||
onStatusChange?: () => void;
|
||||
}
|
||||
|
||||
export const GoogleConnectButton: React.FC<GoogleConnectButtonProps> = ({ ownerId, isConnected, onStatusChange }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3005/api/auth/google/url?dentistId=${ownerId}`);
|
||||
const { url } = await response.json();
|
||||
|
||||
// Abrir em popup para manter o contexto
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const popup = window.open(
|
||||
url,
|
||||
'google-auth',
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
|
||||
// Verificar se o popup fechou a cada 1s
|
||||
const checkPopup = setInterval(() => {
|
||||
if (!popup || popup.closed) {
|
||||
clearInterval(checkPopup);
|
||||
setIsLoading(false);
|
||||
if (onStatusChange) onStatusChange();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao conectar Google Agenda:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('Deseja realmente desconectar esta agenda?')) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetch(`http://localhost:3005/api/auth/google/${ownerId}`, { method: 'DELETE' });
|
||||
if (onStatusChange) onStatusChange();
|
||||
} catch (error) {
|
||||
console.error('Erro ao desconectar:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
|
||||
<div className="p-2 bg-emerald-500 rounded-xl">
|
||||
<CheckCircle2 size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] font-black text-emerald-800 uppercase tracking-tight">Google Agenda Conectada</p>
|
||||
<p className="text-[9px] text-emerald-600 font-bold uppercase tracking-widest mt-0.5">Sincronização Ativa</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
disabled={isLoading}
|
||||
className="text-[9px] font-black text-red-500 hover:text-red-700 uppercase tracking-widest px-3 py-1 bg-white border border-red-100 rounded-lg shadow-sm transition-all"
|
||||
>
|
||||
{isLoading ? <Loader2 size={12} className="animate-spin" /> : 'Desconectar'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isLoading}
|
||||
className="w-full group relative overflow-hidden flex items-center gap-4 bg-white border-2 border-gray-100 p-4 rounded-2xl hover:border-blue-500 hover:shadow-xl hover:shadow-blue-50/50 transition-all duration-300"
|
||||
>
|
||||
<div className="p-3 bg-gray-50 group-hover:bg-blue-600 rounded-xl transition-colors duration-300">
|
||||
<Calendar size={20} className="text-gray-400 group-hover:text-white transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xs font-black text-gray-800 uppercase tracking-tight group-hover:text-blue-600">Conectar Google Agenda</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Sincronize horários externos</p>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="absolute right-4">
|
||||
<Loader2 size={18} className="text-blue-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Building2, ChevronDown, User, LogOut } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const userName = HybridBackend.getCurrentUserName();
|
||||
const userEmail = HybridBackend.getCurrentUser();
|
||||
|
||||
const handleWorkspaceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
HybridBackend.switchWorkspace(e.target.value);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
|
||||
HybridBackend.logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-4 md:px-8 sticky top-0 z-20 shadow-sm">
|
||||
{/* Clinic Selector */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="hidden sm:flex w-10 h-10 bg-blue-50 rounded-xl items-center justify-center text-blue-600 shadow-inner">
|
||||
<Building2 size={20} />
|
||||
</div>
|
||||
<div className="relative w-full max-w-[200px] md:max-w-[300px]">
|
||||
<select
|
||||
value={activeWorkspace?.id || ''}
|
||||
onChange={handleWorkspaceChange}
|
||||
className="w-full bg-gray-50 border border-gray-200 text-gray-800 text-xs font-bold uppercase py-2 pl-3 pr-8 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all appearance-none cursor-pointer truncate"
|
||||
>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Actions */}
|
||||
<div className="flex items-center gap-2 md:gap-4 ml-4">
|
||||
<div className="hidden md:flex flex-col items-end mr-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase leading-none mb-1">Unidade Ativa</span>
|
||||
<span className="text-xs font-bold text-blue-600 uppercase border border-blue-100 bg-blue-50 px-2 py-0.5 rounded shadow-sm">
|
||||
{activeWorkspace?.role || 'Acesso'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-gray-100 hidden sm:block mx-1"></div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs font-bold text-gray-800 truncate max-w-[120px]" title={userName}>
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-[9px] text-gray-400 font-bold truncate max-w-[120px]" title={userEmail}>
|
||||
{userEmail}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-9 h-9 bg-gray-100 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer border border-white shadow-sm">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
|
||||
title="SAIR"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, X, Calendar, DollarSign, AlertCircle, UserCheck, MessageSquare, ShieldAlert } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Notificacao } from '../types.ts';
|
||||
|
||||
export const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notificacoes, setNotificacoes] = useState<Notificacao[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const data = await HybridBackend.getNotifications();
|
||||
setNotificacoes(data);
|
||||
setUnreadCount(data.filter(n => !n.lida).length);
|
||||
} catch (err) {
|
||||
console.error("Erro ao carregar notificações");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
const interval = setInterval(fetch, 30000); // Polling cada 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const toggleOpen = () => setIsOpen(!isOpen);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
await HybridBackend.markNotificationRead(id);
|
||||
setNotificacoes(prev => prev.map(n => n.id === id ? { ...n, lida: true } : n));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const getIcon = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'agenda': return <Calendar size={18} className="text-blue-500" />;
|
||||
case 'financeiro': return <DollarSign size={18} className="text-emerald-500" />;
|
||||
case 'lead': return <UserCheck size={18} className="text-amber-500" />;
|
||||
case 'sistema': return <ShieldAlert size={18} className="text-red-500" />;
|
||||
default: return <MessageSquare size={18} className="text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'agenda': return 'Agenda';
|
||||
case 'financeiro': return 'Financeiro';
|
||||
case 'lead': return 'Novo Lead';
|
||||
case 'sistema': return 'Sistema';
|
||||
default: return 'Geral';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-[1002]">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="group relative w-11 h-11 flex items-center justify-center rounded-xl bg-white/80 backdrop-blur-md border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.06)] hover:shadow-[0_8px_32px_rgba(59,130,246,0.15)] hover:border-blue-400/50 transition-all duration-300"
|
||||
>
|
||||
<Bell size={22} className="text-slate-600 group-hover:text-blue-600 transition-colors" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-5 w-5 bg-red-500 text-[10px] font-bold text-white items-center justify-center shadow-lg">
|
||||
{unreadCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0" onClick={toggleOpen} />
|
||||
<div className="absolute right-0 mt-4 w-[380px] bg-white/95 backdrop-blur-lg rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-white/20 overflow-hidden animate-in fade-in zoom-in-95 slide-in-from-top-4 duration-300 transform origin-top-right">
|
||||
<div className="px-6 py-5 bg-gradient-to-r from-slate-50 to-white border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800 text-lg tracking-tight">Centro de Notificações</h3>
|
||||
<p className="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Você tem {unreadCount} novas mensagens</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<X size={18} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[450px] overflow-y-auto custom-scrollbar bg-slate-50/30">
|
||||
{notificacoes.length === 0 ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center text-center px-8">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Bell size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p className="text-slate-500 font-medium">Tudo limpo por aqui!</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Não há notificações para mostrar no momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{notificacoes.map(notif => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`group p-5 hover:bg-white transition-all duration-200 cursor-default ${!notif.lida ? 'bg-blue-50/40 relative' : ''}`}
|
||||
>
|
||||
{!notif.lida && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 rounded-r-full" />
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div className={`mt-1 h-10 w-10 flex-shrink-0 flex items-center justify-center rounded-xl ${notif.tipo === 'agenda' ? 'bg-blue-100 text-blue-600' :
|
||||
notif.tipo === 'financeiro' ? 'bg-emerald-100 text-emerald-600' :
|
||||
notif.tipo === 'lead' ? 'bg-amber-100 text-amber-600' :
|
||||
'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{getIcon(notif.tipo)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">
|
||||
{getTypeLabel(notif.tipo)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{new Date(notif.data).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className={`text-sm font-bold leading-tight ${!notif.lida ? 'text-slate-900' : 'text-slate-600'}`}>
|
||||
{notif.titulo}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">
|
||||
{notif.mensagem}
|
||||
</p>
|
||||
{!notif.lida && (
|
||||
<button
|
||||
onClick={() => markAsRead(notif.id)}
|
||||
className="text-[11px] text-blue-600 font-bold mt-3 opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
|
||||
>
|
||||
MARCAR COMO LIDA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white border-t border-slate-100 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.hash = '/notificacoes';
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-xs font-bold text-slate-500 hover:text-blue-600 transition-colors tracking-widest uppercase"
|
||||
>
|
||||
Histórico Completo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { NotificationCenter } from './NotificationCenter.tsx';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({ title, description, children }) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 uppercase">{title}</h2>
|
||||
{description && <p className="text-gray-500 text-xs uppercase font-bold mt-1">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users, Calendar as CalendarIcon, LayoutDashboard, Stethoscope,
|
||||
DollarSign, RefreshCw, Database, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3
|
||||
} from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (t: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const userName = HybridBackend.getCurrentUserName();
|
||||
const userEmail = HybridBackend.getCurrentUser();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const clinColor = activeWorkspace?.cor || '#2563eb';
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleStatus();
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'clinicas', label: 'MINHAS CLÍNICAS', icon: Building2 },
|
||||
{ id: 'leads', label: 'GESTÃO DE LEADS', icon: Megaphone },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
{ id: 'tratamentos', label: 'MEUS TRATAMENTOS', icon: ClipboardList },
|
||||
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
|
||||
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
|
||||
{ id: 'dentistas', label: 'DENTISTAS', icon: Stethoscope },
|
||||
{ id: 'especialidades', label: 'ESPECIALIDADES', icon: Award },
|
||||
{ id: 'procedimentos', label: 'PROCEDIMENTOS', icon: ClipboardList },
|
||||
{ id: 'planos', label: 'PLANOS / CONVÊNIOS', icon: BookOpen },
|
||||
{ id: 'notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
|
||||
{ id: 'sync', label: 'SINCRONIZAÇÃO', icon: RefreshCw },
|
||||
].filter(item => {
|
||||
if (['admin', 'donoclinica'].includes(currentRole)) return true;
|
||||
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes'].includes(item.id);
|
||||
if (currentRole === 'dentista') return ['dashboard', 'pacientes', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas'].includes(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
|
||||
HybridBackend.logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col shadow-xl md:shadow-none">
|
||||
<div className="p-6 flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shadow-lg transition-colors duration-500"
|
||||
style={{ backgroundColor: clinColor }}
|
||||
>
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-gray-800 block leading-tight tracking-tight">SCOREODONTO</span>
|
||||
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">MYSQL + GOOGLE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-[11px] font-bold uppercase transition-all duration-300 group ${isActive
|
||||
? 'text-white'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? clinColor : 'transparent',
|
||||
boxShadow: isActive ? `0 10px 15px -5px ${clinColor}44` : 'none'
|
||||
}}
|
||||
>
|
||||
<Icon size={18} className={`${isActive ? 'text-white' : 'text-gray-400 group-hover:text-gray-600'} transition-colors`}
|
||||
style={{ color: isActive ? '#fff' : undefined }} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50/50 space-y-4">
|
||||
{/* User Profile Section */}
|
||||
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-gray-500 border border-gray-200">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-black text-gray-800 uppercase truncate leading-none mb-1">{userName}</p>
|
||||
<p className="text-[9px] font-bold text-gray-400 truncate">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 rounded-lg border transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: `${clinColor}08`,
|
||||
borderColor: `${clinColor}22`
|
||||
}}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shadow-sm" style={{ backgroundColor: clinColor }}></div>
|
||||
<span className="text-[9px] font-black uppercase truncate" style={{ color: clinColor }}>
|
||||
{activeWorkspace?.nome || 'Sem Unidade'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentRole === 'admin' && (
|
||||
<GoogleConnectButton
|
||||
ownerId="admin"
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id === userEmail)}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-red-500 hover:bg-red-50 rounded-xl text-[10px] font-black uppercase transition-all"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
SAIR DO SISTEMA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CheckCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="text-green-500" size={20} />,
|
||||
error: <AlertTriangle className="text-red-500" size={20} />,
|
||||
info: <Info className="text-blue-500" size={20} />,
|
||||
};
|
||||
|
||||
const toastColors = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
info: 'bg-blue-50 border-blue-200',
|
||||
};
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useToast();
|
||||
const portalElement = document.getElementById('toast-container');
|
||||
|
||||
if (!portalElement) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-6 right-6 z-[100] space-y-3">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`w-80 p-4 rounded-xl shadow-lg flex items-start gap-3 border animate-in slide-in-from-top-4 ${toastColors[toast.type]}`}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">{icons[toast.type]}</div>
|
||||
<p className="flex-1 text-sm text-gray-800 font-bold uppercase">{toast.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
portalElement
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: ToastMessage[];
|
||||
addToast: (message: string, type: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
};
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
};
|
||||
|
||||
// FIX: Modified useToast to return the full context including `toasts` array,
|
||||
// so that ToastContainer can access it. Kept helper functions for backward compatibility.
|
||||
export const useToast = (): ToastContextType & {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
} => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
success: (message: string) => context.addToast(message, 'success'),
|
||||
error: (message: string) => context.addToast(message, 'error'),
|
||||
info: (message: string) => context.addToast(message, 'info'),
|
||||
};
|
||||
};
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Fetcher<T> = (...args: any[]) => Promise<T>;
|
||||
|
||||
interface UseHybridBackendReturn<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useHybridBackend<T>(
|
||||
fetcher: Fetcher<T>,
|
||||
deps: any[] = []
|
||||
): UseHybridBackendReturn<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [refreshCount, setRefreshCount] = useState(0);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('An unknown error occurred'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, refreshCount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ScoreOdonto Base Styles */
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.fc {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.fc-toolbar-title {
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background-color: #2563eb !important;
|
||||
border-color: #2563eb !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-event {
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uppercase-input,
|
||||
.uppercase-textarea,
|
||||
.uppercase-select {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dark-date-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* FullCalendar Display Fixes */
|
||||
.fc {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
.fc-timegrid-slot {
|
||||
height: 3em !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell {
|
||||
background-color: #f9fafb;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.fc-scrollgrid {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ScoreOdonto CRM</title>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf('/odonto') !== -1 && !path.endsWith('/') && path.split('/').pop().indexOf('.') === -1) {
|
||||
var newUrl = path + '/' + window.location.search + window.location.hash;
|
||||
window.location.replace(newUrl);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<base href="./">
|
||||
|
||||
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="toast-container"></div>
|
||||
<div id="global-error-display"
|
||||
style="display: none; position: fixed; top: 10px; left: 10px; right: 10px; background-color: #ef4444; color: white; padding: 16px; border-radius: 8px; z-index: 9999; font-family: monospace; font-size: 14px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
||||
import { ToastProvider } from './contexts/ToastContext.tsx';
|
||||
import './index.css';
|
||||
|
||||
// CSS imports are now handled in index.html via <link> tags for AI Studio compatibility,
|
||||
// but we add it here too for Vite dev server robustness.
|
||||
|
||||
/**
|
||||
* AI Studio Compatible Entrypoint
|
||||
*
|
||||
* This version restores the necessary providers (`ErrorBoundary`, `ToastProvider`)
|
||||
* to allow the application to run correctly after the initial bootstrap.
|
||||
* It uses the modern `createRoot` API, which is required for React 18 and newer.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
} else {
|
||||
console.error("CRITICAL: Root element #root not found. App could not be mounted.");
|
||||
const errorDiv = document.getElementById('global-error-display');
|
||||
if (errorDiv) {
|
||||
errorDiv.style.display = 'block';
|
||||
errorDiv.innerText = 'ERRO CRÍTICO: O ELEMENTO #ROOT NÃO FOI ENCONTRADO NO HTML.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Shield, Activity, Heart, Check, ArrowRight, Star, Clock, Users, Smartphone, Zap } from 'lucide-react';
|
||||
|
||||
export const MedLandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white font-sans selection:bg-cyan-500 selection:text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-slate-900/50 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tighter uppercase italic">CRM MÉDICO <span className="text-cyan-400">FIDELIDADE</span></span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-bold uppercase tracking-widest text-slate-400">
|
||||
<a href="#planos" className="hover:text-white transition-colors">Planos</a>
|
||||
<a href="#beneficios" className="hover:text-white transition-colors">Benefícios</a>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="hover:text-white transition-colors uppercase"
|
||||
>
|
||||
Acesso Restrito
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="px-6 py-2.5 bg-white text-slate-900 rounded-full hover:bg-cyan-400 transition-all hover:-translate-y-1"
|
||||
>
|
||||
Voltar ao CRM Odonto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-40 pb-20 overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-cyan-500/10 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-blue-600/10 rounded-full blur-[120px] animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-full text-xs font-black uppercase tracking-widest text-cyan-400 mb-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<Zap size={14} /> Novo: Fidelidade Multimédica
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-black tracking-tighter leading-[0.9] text-white mb-8 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||
SAÚDE TOTAL, <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600">SEM COMPROMISSO.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 max-w-xl leading-relaxed mb-12 animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200">
|
||||
O primeiro plano de fidelidade que integra medicina, odontologia e bem-estar em um único ecossistema. Fidelize sua saúde com tecnologia de ponta.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 animate-in fade-in slide-in-from-bottom-10 duration-1000 delay-500">
|
||||
<button className="px-10 py-5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-2xl font-black uppercase tracking-widest hover:scale-105 transition-all shadow-xl shadow-cyan-500/20 flex items-center gap-3">
|
||||
COMEÇAR AGORA <ArrowRight size={20} />
|
||||
</button>
|
||||
<button className="px-10 py-5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl font-black uppercase tracking-widest transition-all">
|
||||
VER PLANOS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
<section id="planos" className="py-32 bg-slate-900/40 relative">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">ESTRUTURA DE <span className="text-cyan-400">PLANOS</span></h2>
|
||||
<p className="text-slate-400 uppercase tracking-[0.3em] font-bold text-xs">Exclusivo: Combos que não aceitam desmembramento</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-10 max-w-5xl mx-auto">
|
||||
{/* Base Plan */}
|
||||
<div className="group relative bg-white/5 border border-white/10 rounded-[3rem] p-12 hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute top-8 right-8 text-slate-700 font-black text-6xl group-hover:text-slate-600 transition-colors">01</div>
|
||||
<div className="mb-10">
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA BASE (OBRIGATÓRIO)</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + <br />ODONTO</h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Consultas Médicas ilimitadas',
|
||||
'Limpeza e Prevenção Dental',
|
||||
'Urgências 24h',
|
||||
'Rede Credenciada Platinum',
|
||||
'Descontos em Medicamentos'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 149</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all">
|
||||
SELECIONAR PLANO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Premium Plan */}
|
||||
<div className="group relative bg-white/5 border-2 border-cyan-500/50 rounded-[3rem] p-12 overflow-hidden hover:bg-white/[0.08] transition-all hover:-translate-y-2">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="absolute top-8 right-8 text-cyan-900/30 font-black text-6xl group-hover:text-cyan-900/50 transition-colors">02</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-4 py-1.5 bg-gradient-to-r from-amber-400 to-orange-500 text-black text-[10px] font-black uppercase tracking-widest rounded-full">MAIS POPULAR</span>
|
||||
</div>
|
||||
<span className="px-4 py-1.5 bg-cyan-500/20 text-cyan-400 text-[10px] font-black uppercase tracking-widest rounded-full">LINHA PREMIUM</span>
|
||||
<h3 className="text-4xl font-black tracking-tighter mt-4">MÉDICO + ODONTO <br /><span className="text-cyan-400">+ PSICOLOGIA</span></h3>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-12">
|
||||
{[
|
||||
'Tudo da Linha Base',
|
||||
'Suporte Psicológico 24h',
|
||||
'Sessões de Terapia Mensais',
|
||||
'Check-up Executivo Anual',
|
||||
'Prioridade na Agenda (VIP)'
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-slate-300 font-medium">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-500 flex items-center justify-center text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-end gap-2 mb-8">
|
||||
<span className="text-5xl font-black leading-none">R$ 249</span>
|
||||
<span className="text-slate-500 font-bold uppercase text-[10px] tracking-widest mb-1">/mês individual</span>
|
||||
</div>
|
||||
<button className="w-full py-5 bg-gradient-to-r from-cyan-500 to-blue-600 text-white rounded-2xl font-black uppercase tracking-widest hover:scale-[1.02] transition-all shadow-lg shadow-cyan-500/20">
|
||||
ASSINAR PREMIUM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features (CRM) */}
|
||||
<section id="beneficios" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ icon: Users, title: 'TITULAR E DEP.', desc: 'Gestão completa familiar com carteirinha individual digital.' },
|
||||
{ icon: Shield, title: 'ESTABILIDADE', desc: 'Previsibilidade total para o médico e segurança para o paciente.' },
|
||||
{ icon: Smartphone, title: 'WALLET DIGITAL', desc: 'QR Code e histórico médico sempre no seu bolso.' },
|
||||
{ icon: Zap, title: 'SEM GLOSA', desc: 'Processamento automático e repasse médico instantâneo.' }
|
||||
].map((feat, i) => (
|
||||
<div key={i} className="p-8 bg-white/2 hover:bg-white/5 border border-white/5 rounded-3xl transition-all">
|
||||
<feat.icon className="text-cyan-400 mb-6" size={32} />
|
||||
<h4 className="text-lg font-black tracking-widest uppercase mb-4">{feat.title}</h4>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{feat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-white/5 bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
||||
<Activity className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="font-black uppercase tracking-tighter">CRM MÉDICO</span>
|
||||
</div>
|
||||
<div className="text-slate-500 text-[10px] font-black uppercase tracking-[0.4em]">
|
||||
© 2026 SCOREODONTO HYBRID MEDICAL ENGINE. ALL RIGHTS RESERVED.
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => window.location.hash = '/'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Sair para Odonto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="text-slate-400 hover:text-white transition-colors uppercase font-bold text-xs tracking-widest"
|
||||
>
|
||||
Painel Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-in {
|
||||
animation: animate-in 0.8s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users, Calendar, DollarSign, Activity,
|
||||
ArrowUpRight, ArrowDownRight, Zap, Target,
|
||||
TrendingUp, Clock, AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
export const MedDashboard: React.FC = () => {
|
||||
const stats = [
|
||||
{ label: 'Pacientes Ativos', value: '1.284', change: '+12%', positive: true, icon: Users, color: 'cyan' },
|
||||
{ label: 'Consultas Hoje', value: '24', change: '+4', positive: true, icon: Calendar, color: 'blue' },
|
||||
{ label: 'Receita Est. (MRR)', value: 'R$ 84.200', change: '+8%', positive: true, icon: DollarSign, color: 'emerald' },
|
||||
{ label: 'Taxa de Fidelidade', value: '94.2%', change: '-2%', positive: false, icon: Activity, color: 'purple' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-white mb-2 uppercase italic">Painel <span className="text-cyan-400">Estratégico</span></h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Visão geral do ecossistema médico e fidelidade</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={i} className="bg-white/5 border border-white/10 p-8 rounded-[2rem] hover:bg-white/[0.08] transition-all group">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-4 rounded-2xl bg-${stat.color}-500/10 text-${stat.color}-400 group-hover:scale-110 transition-transform`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-[10px] font-black uppercase tracking-widest ${stat.positive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{stat.positive ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
{stat.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-slate-500 text-[10px] font-black uppercase tracking-[0.2em]">{stat.label}</span>
|
||||
<div className="text-3xl font-black text-white tracking-tighter">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-[2.5rem] overflow-hidden">
|
||||
<div className="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.3em] text-white">Próximos Atendimentos</h3>
|
||||
<button className="text-[10px] font-black text-cyan-400 hover:text-cyan-300 uppercase tracking-widest transition-colors">Ver Agenda Completa</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{[
|
||||
{ name: 'Ricardo Santos', time: '09:00', type: 'Consulta Geral', plan: 'Premium' },
|
||||
{ name: 'Ana Oliveira', time: '10:30', type: 'Retorno', plan: 'Base' },
|
||||
{ name: 'Marcos Viana', time: '13:00', type: 'Primeira Vez', plan: 'Premium' },
|
||||
{ name: 'Juliana Costa', time: '15:15', type: 'Check-up', plan: 'Base' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 hover:bg-white/5 rounded-2xl transition-all cursor-pointer group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-800 flex flex-col items-center justify-center border border-white/5">
|
||||
<span className="text-[10px] font-black text-slate-500 leading-none">HRS</span>
|
||||
<span className="text-sm font-black text-white">{item.time}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight group-hover:text-cyan-400 transition-colors">{item.name}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">{item.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest ${item.plan === 'Premium' ? 'bg-cyan-500/10 text-cyan-400' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
Plano {item.plan}
|
||||
</span>
|
||||
<button className="p-2 text-slate-600 hover:text-white transition-colors">
|
||||
<ArrowUpRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intelligent Insights */}
|
||||
<div className="bg-gradient-to-b from-cyan-600/20 to-transparent border border-cyan-500/20 rounded-[2.5rem] p-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-500/20 blur-[60px]"></div>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-cyan-500 rounded-2xl flex items-center justify-center text-white mb-8 shadow-lg shadow-cyan-500/20">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white tracking-tighter mb-4 italic uppercase">Inteligência <br />Médica</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={14} className="text-amber-400" />
|
||||
<span className="text-[10px] font-black text-amber-400 uppercase tracking-widest">Alerta de Agenda</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">3 pacientes do Plano Base estão aptos para upgrade para o Premium este mês.</p>
|
||||
</div>
|
||||
<div className="p-5 bg-black/20 rounded-2xl border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={14} className="text-emerald-400" />
|
||||
<span className="text-[10px] font-black text-emerald-400 uppercase tracking-widest">Performance</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed font-medium">Sua produtividade aumentou 15% após a integração com o módulo Dental.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full mt-10 py-5 bg-white text-slate-900 rounded-2xl font-black uppercase tracking-widest hover:bg-cyan-400 transition-all flex items-center justify-center gap-3">
|
||||
Ver Insights <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrowRight: React.FC<{ size: number }> = ({ size }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14m-7-7 7 7-7 7" /></svg>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MedSidebar } from './MedSidebar.tsx';
|
||||
import { Bell, Search, Settings, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface MedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentView: string;
|
||||
onNavigate: (view: any) => void;
|
||||
}
|
||||
|
||||
export const MedLayout: React.FC<MedLayoutProps> = ({ children, currentView, onNavigate }) => {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#020617] text-slate-200 overflow-hidden font-sans">
|
||||
{/* Sidebar */}
|
||||
<MedSidebar activeTab={currentView} setActiveTab={onNavigate} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 ml-72 flex flex-col h-screen overflow-hidden">
|
||||
{/* Premium Top Bar */}
|
||||
<header className="h-24 px-10 flex items-center justify-between border-b border-white/5 bg-[#020617]/50 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div className="flex items-center gap-6 flex-1 max-w-xl">
|
||||
<div className="relative group w-full">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-400 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="BUSCAR PACIENTES, AGENDAMENTOS OU GUIAS..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-6 text-xs font-bold uppercase tracking-widest focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.08] transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ icon: Bell, alert: true },
|
||||
{ icon: HelpCircle, alert: false },
|
||||
{ icon: Settings, alert: false }
|
||||
].map((item, i) => (
|
||||
<button key={i} className="w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-all relative">
|
||||
<item.icon size={20} className="text-slate-400" />
|
||||
{item.alert && <div className="absolute top-3 right-3 w-2.5 h-2.5 bg-cyan-500 border-2 border-[#020617] rounded-full"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-px bg-white/10 mx-2"></div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-2">
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-black text-cyan-500 block leading-none uppercase tracking-widest">Acesso Premium</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-tighter">Portal Médico Cassems</span>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-cyan-500 to-blue-600 p-[2px] shadow-lg shadow-cyan-500/20">
|
||||
<div className="w-full h-full rounded-full bg-slate-900 flex items-center justify-center text-xs font-black text-white">
|
||||
MED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Dashboard/View Container */}
|
||||
<main className="flex-1 overflow-y-auto p-10 custom-scrollbar">
|
||||
<div className="max-w-7xl mx-auto pb-20">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, ClipboardList,
|
||||
Activity, Shield, DollarSign, LogOut, Settings,
|
||||
ChevronRight, Zap, Heart, Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MedSidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const MedSidebar: React.FC<MedSidebarProps> = ({ activeTab, setActiveTab }) => {
|
||||
const menuItems = [
|
||||
{ id: 'med-dashboard', label: 'Painel Geral', icon: LayoutDashboard },
|
||||
{ id: 'med-pacientes', label: 'Pacientes', icon: Users },
|
||||
{ id: 'med-agenda', label: 'Agenda Médica', icon: Calendar },
|
||||
{ id: 'med-prontuarios', label: 'Prontuários', icon: ClipboardList },
|
||||
{ id: 'med-fidelidade', label: 'Planos Fidelidade', icon: Shield },
|
||||
{ id: 'med-financeiro', label: 'Financeiro / MRR', icon: DollarSign },
|
||||
{ id: 'medical-repasse', label: 'Repasse Médico', icon: Zap },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('Deseja realmente sair do portal médico?')) {
|
||||
window.location.hash = '/med/landingpage';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-[#0f172a] h-screen flex flex-col border-r border-white/5 fixed left-0 top-0 z-50 overflow-hidden">
|
||||
{/* Logo Section */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveTab('med-dashboard')}>
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-cyan-500/20 group-hover:scale-110 transition-transform">
|
||||
<Activity className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-black tracking-tighter text-white block leading-none italic">CRM <span className="text-cyan-400">MÉDICO</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Premium Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 space-y-2 overflow-y-auto py-4">
|
||||
<div className="px-4 mb-4">
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-[0.3em]">Navegação Principal</span>
|
||||
</div>
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3.5 rounded-xl transition-all group ${isActive
|
||||
? 'bg-gradient-to-r from-cyan-500/10 to-transparent border-l-4 border-cyan-500 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={20} className={isActive ? 'text-cyan-400' : 'group-hover:text-cyan-400 transition-colors'} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
{isActive && <ChevronRight size={14} className="text-cyan-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section / User Profile */}
|
||||
<div className="p-6 bg-slate-900/40 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 mb-6 px-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 border border-white/10 flex items-center justify-center text-cyan-400 font-black text-xs">
|
||||
DR
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-black text-white block truncate uppercase tracking-tighter">Dr. Profissional</span>
|
||||
<span className="text-[10px] text-cyan-500/80 font-bold uppercase tracking-widest">Médico Diretor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all text-xs font-bold uppercase tracking-widest">
|
||||
<Settings size={18} />
|
||||
Configurações
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sair do Portal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="px-8 py-4 bg-slate-950/50 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,211,238,0.5)]"></div>
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Sincronizado</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ScoreOdonto CRM",
|
||||
"description": "Sistema de gerenciamento para clínica odontológica com integração híbrida (MySQL + Google Sheets) e design moderno.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
Generated
+5524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "scoreodonto-crm",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"server": "node server.js",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/client": "^7.4.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^171.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mysql2": "^3.17.4",
|
||||
"pg": "^8.11.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"prisma": "^7.4.1",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import pg from 'pg';
|
||||
const { Pool } = pg;
|
||||
|
||||
// Simple query translator from MySQL dialect to PostgreSQL
|
||||
function translateQuery(query, params) {
|
||||
if (typeof query !== 'string') return { sql: query, pgParams: params };
|
||||
|
||||
let sql = query;
|
||||
let pgParams = params ? [...params] : [];
|
||||
|
||||
// Remove or convert backticks
|
||||
sql = sql.replace(/`([^`]+)`/g, '"$1"');
|
||||
|
||||
// INSERT INTO table SET ?
|
||||
if (sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)) {
|
||||
const table = sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)[1].replace(/"/g, '');
|
||||
const dataObj = pgParams[0];
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
const cols = keys.map(k => `"${k}"`).join(', ');
|
||||
const valuesPlaceholder = keys.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
sql = `INSERT INTO "${table}" (${cols}) VALUES (${valuesPlaceholder})`;
|
||||
pgParams = keys.map(k => dataObj[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE table SET ? WHERE ...
|
||||
if (sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i)) {
|
||||
const match = sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i);
|
||||
const table = match[1].replace(/"/g, '');
|
||||
const whereClause = match[2];
|
||||
const dataObj = pgParams[0];
|
||||
const extraParams = pgParams.slice(1);
|
||||
|
||||
if (dataObj && typeof dataObj === 'object') {
|
||||
const keys = Object.keys(dataObj);
|
||||
let paramIdx = 1;
|
||||
const setClause = keys.map(k => `"${k}" = $${paramIdx++}`).join(', ');
|
||||
|
||||
let postgresWhere = whereClause;
|
||||
let tempIdx = paramIdx;
|
||||
postgresWhere = postgresWhere.replace(/\?/g, () => `$${tempIdx++}`);
|
||||
|
||||
sql = `UPDATE "${table}" SET ${setClause} WHERE ${postgresWhere}`;
|
||||
pgParams = [...keys.map(k => dataObj[k]), ...extraParams];
|
||||
}
|
||||
}
|
||||
|
||||
// Translate standard '?' to '$1', '$2', etc.
|
||||
let count = 1;
|
||||
sql = sql.replace(/\?/g, () => `$${count++}`);
|
||||
|
||||
// ON DUPLICATE KEY UPDATE -> ON CONFLICT
|
||||
if (sql.match(/ON\s+DUPLICATE\s+KEY\s+UPDATE/i)) {
|
||||
if (sql.toLowerCase().includes('settings')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*VALUES\(data\)|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*\$4|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*EXCLUDED\.data/i, 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data');
|
||||
} else if (sql.toLowerCase().includes('google_tokens')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (owner_id) DO UPDATE SET refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date');
|
||||
} else if (sql.toLowerCase().includes('usuarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf, celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('vinculos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = EXCLUDED.role');
|
||||
} else if (sql.toLowerCase().includes('dentistas_horarios')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio, hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo');
|
||||
} else if (sql.toLowerCase().includes('dentistas')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade');
|
||||
} else if (sql.toLowerCase().includes('procedimentos_valores_planos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor');
|
||||
} else if (sql.toLowerCase().includes('procedimentos')) {
|
||||
sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, "especialidadeId" = EXCLUDED."especialidadeId", "especialidadeNome" = EXCLUDED."especialidadeNome", "valorParticular" = EXCLUDED."valorParticular", "codigoInterno" = EXCLUDED."codigoInterno", categoria = EXCLUDED.categoria, tipo_regiao = EXCLUDED.tipo_regiao, exige_face = EXCLUDED.exige_face, codigo = EXCLUDED.codigo');
|
||||
}
|
||||
}
|
||||
|
||||
// DATE_FORMAT -> TO_CHAR
|
||||
sql = sql.replace(/DATE_FORMAT\(([^,]+),\s*['"]%Y-%m['"]\)/gi, "TO_CHAR($1, 'YYYY-MM')");
|
||||
// DATE_SUB -> CURRENT_DATE - INTERVAL
|
||||
sql = sql.replace(/DATE_SUB\(CURDATE\(\),\s*INTERVAL\s+(\d+)\s+MONTH\)/gi, "CURRENT_DATE - INTERVAL '$1 MONTH'");
|
||||
|
||||
// SET FOREIGN_KEY_CHECKS
|
||||
if (sql.match(/SET\s+FOREIGN_KEY_CHECKS\s*=\s*\d+/i)) {
|
||||
sql = "SELECT 1";
|
||||
}
|
||||
|
||||
return { sql, pgParams };
|
||||
}
|
||||
|
||||
class PostgresConnection {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.client.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async beginTransaction() {
|
||||
await this.client.query('BEGIN');
|
||||
}
|
||||
|
||||
async commit() {
|
||||
await this.client.query('COMMIT');
|
||||
}
|
||||
|
||||
async rollback() {
|
||||
await this.client.query('ROLLBACK');
|
||||
}
|
||||
|
||||
release() {
|
||||
this.client.release();
|
||||
}
|
||||
}
|
||||
|
||||
class PostgresPool {
|
||||
constructor(config) {
|
||||
// Convert connection string from postgresql:// to pool config
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
this.pool = new Pool({
|
||||
connectionString: connectionString || `postgresql://${config.user}:${config.password}@${config.host}:5432/${config.database}`,
|
||||
ssl: connectionString && connectionString.includes('localhost') ? false : { rejectUnauthorized: false }
|
||||
});
|
||||
}
|
||||
|
||||
async query(query, params) {
|
||||
const { sql, pgParams } = translateQuery(query, params);
|
||||
const res = await this.pool.query(sql, pgParams);
|
||||
return [res.rows, res.fields];
|
||||
}
|
||||
|
||||
async getConnection() {
|
||||
const client = await this.pool.connect();
|
||||
return new PostgresConnection(client);
|
||||
}
|
||||
|
||||
async end() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createPool: (config) => new PostgresPool(config)
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Beneficiario {
|
||||
id String @id @default(uuid())
|
||||
nome String
|
||||
identificacao String @unique
|
||||
guias GuiaOdontologica[]
|
||||
gtoBuilders GtoBuilder[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GuiaOdontologica {
|
||||
id String @id @default(uuid())
|
||||
numeroGuiaPrestador String
|
||||
numeroGuiaOperadora String?
|
||||
dataSolicitacao DateTime
|
||||
tipoTratamento String
|
||||
status StatusGuia
|
||||
beneficiarioId String
|
||||
beneficiario Beneficiario @relation(fields: [beneficiarioId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoBuilder {
|
||||
id String @id @default(uuid())
|
||||
pacienteId String
|
||||
paciente Beneficiario @relation(fields: [pacienteId], references: [id])
|
||||
dentistaId String
|
||||
credenciadaId String
|
||||
status String @default("RASCUNHO") // RASCUNHO | FINALIZADA
|
||||
total Float @default(0)
|
||||
items GtoItem[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model GtoItem {
|
||||
id String @id @default(uuid())
|
||||
builderId String
|
||||
builder GtoBuilder @relation(fields: [builderId], references: [id])
|
||||
procedimentoId String
|
||||
codigoTUSS String
|
||||
descricao String
|
||||
dente String?
|
||||
face String?
|
||||
quantidade Int @default(1)
|
||||
valorUnitario Float
|
||||
valorTotal Float
|
||||
observacao String?
|
||||
anexosCount Int @default(0)
|
||||
}
|
||||
|
||||
enum StatusGuia {
|
||||
EM_ANALISE
|
||||
AUTORIZADO
|
||||
AUTORIZADO_PARCIAL
|
||||
NEGADO
|
||||
CANCELADO
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
import { Agendamento, Dentista, DentistaHorario, Especialidade, EvolucaoOrto, FinanceiroItem, Lead, Notificacao, Paciente, Plano, Procedimento, Settings, TratamentoOrto } from '../types.ts';
|
||||
|
||||
// Helper to enforce uppercase on strings
|
||||
const toUpper = (str: string | undefined) => str ? str.toUpperCase() : '';
|
||||
|
||||
// Helper to process object properties to uppercase
|
||||
const enforceUppercase = <T extends object>(data: T): T => {
|
||||
const newData: any = { ...data };
|
||||
for (const key in newData) {
|
||||
if (typeof newData[key] === 'string' && key !== 'id' && key !== 'email' && key !== 'start' && key !== 'end' && !key.includes('data') && !key.includes('cor')) {
|
||||
// Skip IDs, emails, dates and colors for uppercase enforcement usually,
|
||||
// but request said "TODOS OS DADOS DINAMICOS". We will be aggressive but keep IDs and technical fields intact.
|
||||
// We'll primarily target user-visible text fields.
|
||||
if (key !== 'corAgenda' && key !== 'corCartao') {
|
||||
newData[key] = newData[key].toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005/api';
|
||||
|
||||
// Returns headers always including the JWT token stored after login
|
||||
const getAuthHeaders = (): HeadersInit => {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const HybridBackend = {
|
||||
// Configurações do Banco de Dados fornecidas
|
||||
dbConfig: {
|
||||
host: 'localhost',
|
||||
user: 'root', // Exemplo
|
||||
password: '',
|
||||
database: 'sis_odonto'
|
||||
},
|
||||
|
||||
// --- AUTH METHODS ---
|
||||
login: async (email: string, pass: string): Promise<boolean> => {
|
||||
const cleanEmail = email.toLowerCase().trim();
|
||||
const cleanPass = pass.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: cleanEmail, password: cleanPass })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('SCOREODONTO_AUTH_TOKEN', data.token);
|
||||
localStorage.setItem('SCOREODONTO_USER_DATA', JSON.stringify(data.user));
|
||||
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(data.workspaces));
|
||||
|
||||
// Default to first workspace
|
||||
if (data.workspaces && data.workspaces.length > 0) {
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(data.workspaces[0]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('SCOREODONTO_AUTH_TOKEN');
|
||||
localStorage.removeItem('SCOREODONTO_USER_DATA');
|
||||
localStorage.removeItem('SCOREODONTO_WORKSPACES');
|
||||
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
|
||||
window.location.hash = '/';
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
},
|
||||
|
||||
getCurrentUser: () => {
|
||||
const data = localStorage.getItem('SCOREODONTO_USER_DATA');
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data).email;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentUserName: () => {
|
||||
const data = localStorage.getItem('SCOREODONTO_USER_DATA');
|
||||
if (!data) return 'USUÁRIO';
|
||||
try {
|
||||
return JSON.parse(data).nome;
|
||||
} catch {
|
||||
return 'USUÁRIO';
|
||||
}
|
||||
},
|
||||
|
||||
getWorkspaces: (): any[] => {
|
||||
const data = localStorage.getItem('SCOREODONTO_WORKSPACES');
|
||||
return data ? JSON.parse(data) : [];
|
||||
},
|
||||
|
||||
getActiveWorkspace: (): any | null => {
|
||||
const data = localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE');
|
||||
return data ? JSON.parse(data) : null;
|
||||
},
|
||||
|
||||
switchWorkspace: (workspaceId: string) => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
if (workspace) {
|
||||
// ─── CACHE CLEARING: purge all tenant-specific data before switching ───
|
||||
// This prevents data from clinic A from leaking into the context of clinic B
|
||||
const TENANT_CACHE_KEYS = [
|
||||
'SCOREODONTO_CACHE_PACIENTES',
|
||||
'SCOREODONTO_CACHE_AGENDAMENTOS',
|
||||
'SCOREODONTO_CACHE_FINANCEIRO',
|
||||
'SCOREODONTO_CACHE_DENTISTAS',
|
||||
'SCOREODONTO_CACHE_GUIAS',
|
||||
'SCOREODONTO_CACHE_GTO',
|
||||
'SCOREODONTO_CACHE_PROCEDIMENTOS',
|
||||
'SCOREODONTO_CACHE_PLANOS',
|
||||
'SCOREODONTO_CACHE_NOTIFICACOES',
|
||||
];
|
||||
TENANT_CACHE_KEYS.forEach(k => localStorage.removeItem(k));
|
||||
console.log(`[SECURITY] Cache cleared for tenant switch to: ${workspaceId}`);
|
||||
// ─── Set new active workspace and reload to flush React state completely ───
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(workspace));
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentRole: (): string => {
|
||||
const workspace = HybridBackend.getActiveWorkspace();
|
||||
return workspace ? workspace.role : 'paciente';
|
||||
},
|
||||
|
||||
getDashboardStats: async () => {
|
||||
const res = await fetch(`${API_URL}/dashboard/stats`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
// --- SYSTEM CHECK METHODS ---
|
||||
isDbConfigured: (): boolean => {
|
||||
return localStorage.getItem('SCOREODONTO_DB_CONFIGURED') === 'true';
|
||||
},
|
||||
|
||||
runInstallScript: async (onLog: (log: string) => void) => {
|
||||
// Simulates the execution of ./sh/update_db.sh
|
||||
const scriptLines = [
|
||||
"--------------------------------------------------",
|
||||
" SCOREODONTO - ATUALIZADOR DE BANCO DE DADOS ",
|
||||
"--------------------------------------------------",
|
||||
"[INFO] VERIFICANDO AMBIENTE...",
|
||||
"[OK] AMBIENTE MYSQL DETECTADO.",
|
||||
`[INFO] CONECTANDO DB: ${HybridBackend.dbConfig.database}...`,
|
||||
`[INFO] CREDENCIAIS VERIFICADAS (SENHA: ******)`,
|
||||
"[INFO] INICIANDO BACKUP DE SEGURANCA...",
|
||||
`[OK] BACKUP SALVO EM: ./bkp_database/BKP_${new Date().toISOString().replace(/[:.]/g, '-')}.SQL`,
|
||||
"[INFO] CONECTANDO API GOOGLE SHEETS...",
|
||||
"[INFO] LENDO PLANILHA: 'Dados Pacientes'...",
|
||||
" > 1.240 LINHAS ENCONTRADAS.",
|
||||
" > VALIDANDO DADOS (CPF, TELEFONE)...",
|
||||
"[INFO] SINCRONIZANDO COM MYSQL (sis_odonto)...",
|
||||
" > FORCANDO PADRAO 'UPPERCASE' EM TODOS OS CAMPOS TEXTUAIS...",
|
||||
" > ATUALIZANDO TABELA: PACIENTES...",
|
||||
" > ATUALIZANDO TABELA: FINANCEIRO...",
|
||||
" > ATUALIZANDO TABELA: AGENDA...",
|
||||
"[INFO] LIMPANDO CACHE DE SESSAO...",
|
||||
"--------------------------------------------------",
|
||||
" ATUALIZACAO CONCLUIDA COM SUCESSO! ",
|
||||
"--------------------------------------------------"
|
||||
];
|
||||
|
||||
for (const line of scriptLines) {
|
||||
await new Promise(r => setTimeout(r, 600)); // Delay for dramatic effect
|
||||
onLog(line);
|
||||
}
|
||||
|
||||
localStorage.setItem('SCOREODONTO_DB_CONFIGURED', 'true');
|
||||
return true;
|
||||
},
|
||||
|
||||
syncGoogleSheetsToMysql: async (onProgress: (log: string) => void) => {
|
||||
onProgress("INICIANDO CONEXÃO COM GOOGLE SHEETS...");
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress("LENDO ABA 'DADOS PACIENTES'...");
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress(`CONECTANDO AO MYSQL (${HybridBackend.dbConfig.database})...`);
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress("COMPARANDO REGISTROS (HASH CHECK)...");
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
onProgress("INSERINDO 2 NOVOS PACIENTES NO MYSQL...");
|
||||
onProgress("ATUALIZANDO 4 AGENDAMENTOS...");
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
onProgress("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!");
|
||||
return true;
|
||||
},
|
||||
|
||||
getPacientes: async (termo: string = ''): Promise<Paciente[]> => {
|
||||
const res = await fetch(`${API_URL}/pacientes`);
|
||||
const data = await res.json();
|
||||
if (!termo) return data;
|
||||
const t = termo.toUpperCase();
|
||||
return data.filter((p: Paciente) => p.nome.includes(t) || p.cpf.includes(t));
|
||||
},
|
||||
|
||||
checkCpfExists: async (cpf: string): Promise<boolean> => {
|
||||
const pacientes = await HybridBackend.getPacientes();
|
||||
const cleanCpf = cpf.replace(/\D/g, '');
|
||||
if (!cleanCpf) return false;
|
||||
return pacientes.some(p => p.cpf.replace(/\D/g, '') === cleanCpf);
|
||||
},
|
||||
|
||||
savePaciente: async (paciente: Partial<Paciente>) => {
|
||||
const data = enforceUppercase(paciente);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/pacientes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updatePaciente: async (paciente: Paciente) => {
|
||||
const data = enforceUppercase(paciente);
|
||||
const res = await fetch(`${API_URL}/pacientes/${paciente.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getDentistas: async (clinicaId?: string): Promise<Dentista[]> => {
|
||||
const url = clinicaId ? `${API_URL}/dentistas?clinicaId=${clinicaId}` : `${API_URL}/dentistas`;
|
||||
const res = await fetch(url);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getPlanos: async (): Promise<Plano[]> => {
|
||||
const res = await fetch(`${API_URL}/planos`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateDentista: async (dentista: Dentista) => {
|
||||
const data = enforceUppercase(dentista);
|
||||
const res = await fetch(`${API_URL}/dentistas/${dentista.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveDentista: async (dentista: Partial<Dentista>) => {
|
||||
const activeW = HybridBackend.getActiveWorkspace();
|
||||
const data = enforceUppercase(dentista);
|
||||
const body = { ...data, id: Math.random().toString(), clinica_id: activeW?.id };
|
||||
const res = await fetch(`${API_URL}/dentistas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderDentistas: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/dentistas/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteDentista: async (id: string) => {
|
||||
await fetch(`${API_URL}/dentistas/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getHorariosByDentista: async (dentistaId: string, clinicaId: string): Promise<DentistaHorario[]> => {
|
||||
const res = await fetch(`${API_URL}/dentistas/${dentistaId}/horarios?clinicaId=${clinicaId}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveHorario: async (horario: Partial<DentistaHorario>) => {
|
||||
const res = await fetch(`${API_URL}/dentistas/horarios`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(horario)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteHorario: async (id: string) => {
|
||||
await fetch(`${API_URL}/dentistas/horarios/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getProductivityReport: async (clinicaId: string, start?: string, end?: string) => {
|
||||
const res = await fetch(`${API_URL}/reports/productivity?clinicaId=${clinicaId}&start=${start || ''}&end=${end || ''}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateEspecialidade: async (esp: Especialidade) => {
|
||||
const data = enforceUppercase(esp);
|
||||
const res = await fetch(`${API_URL}/especialidades/${esp.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveEspecialidade: async (esp: Partial<Especialidade>) => {
|
||||
const data = enforceUppercase(esp);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/especialidades`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderEspecialidades: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/especialidades/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteEspecialidade: async (id: string) => {
|
||||
await fetch(`${API_URL}/especialidades/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
updateProcedimento: async (proc: Procedimento) => {
|
||||
const data = enforceUppercase(proc);
|
||||
const res = await fetch(`${API_URL}/procedimentos/${proc.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveProcedimento: async (proc: Partial<Procedimento>) => {
|
||||
const data = enforceUppercase(proc);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/procedimentos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
reorderProcedimentos: async (orders: { id: string; ordem: number }[]) => {
|
||||
const res = await fetch(`${API_URL}/procedimentos/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteProcedimento: async (id: string) => {
|
||||
await fetch(`${API_URL}/procedimentos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
updatePlano: async (plano: Plano) => {
|
||||
const data = enforceUppercase(plano);
|
||||
const res = await fetch(`${API_URL}/planos/${plano.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
savePlano: async (plano: Partial<Plano>) => {
|
||||
const data = enforceUppercase(plano);
|
||||
const body = { ...data, id: Math.random().toString() };
|
||||
const res = await fetch(`${API_URL}/planos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deletePlano: async (id: string) => {
|
||||
await fetch(`${API_URL}/planos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getEspecialidades: async (): Promise<Especialidade[]> => {
|
||||
const res = await fetch(`${API_URL}/especialidades`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getProcedimentos: async (): Promise<Procedimento[]> => {
|
||||
const res = await fetch(`${API_URL}/procedimentos`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getFinanceiro: async (): Promise<FinanceiroItem[]> => {
|
||||
const res = await fetch(`${API_URL}/financeiro`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
|
||||
const data = enforceUppercase(item);
|
||||
const res = await fetch(`${API_URL}/financeiro`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
updateFinanceiro: async (item: FinanceiroItem) => {
|
||||
const data = enforceUppercase(item);
|
||||
const res = await fetch(`${API_URL}/financeiro/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
deleteFinanceiro: async (id: string) => {
|
||||
await fetch(`${API_URL}/financeiro/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getAgendamentos: async (): Promise<Agendamento[]> => {
|
||||
const res = await fetch(`${API_URL}/agendamentos`, { headers: getAuthHeaders() });
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
salvarAgendamento: async (ag: Partial<Agendamento>) => {
|
||||
const res = await fetch(`${API_URL}/agendamentos`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(ag)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Erro ao salvar agendamento.');
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
atualizarAgendamento: async (id: string, data: Partial<Agendamento>) => {
|
||||
const res = await fetch(`${API_URL}/agendamentos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Erro ao atualizar agendamento.');
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
excluirAgendamento: async (id: string) => {
|
||||
await fetch(`${API_URL}/agendamentos/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||
},
|
||||
|
||||
getOrtoTreatments: async (): Promise<TratamentoOrto[]> => {
|
||||
return [
|
||||
{
|
||||
id: 't1',
|
||||
pacienteId: '1',
|
||||
pacienteNome: 'ANA SILVA',
|
||||
dataNascimento: '2014-03-15',
|
||||
responsavel: 'MARIA SILVA',
|
||||
tipoPaciente: 'Adolescente',
|
||||
triagem: {
|
||||
queixaPrincipal: 'DENTES TORTOS NA FRENTE',
|
||||
historicoMedico: 'NENHUMA CONDIÇÃO RELEVANTE',
|
||||
medicacao: 'NENHUMA',
|
||||
alergias: 'NENHUMA',
|
||||
respiracaoBucal: false,
|
||||
habitos: 'BRUXISMO LEVE NOTURNO',
|
||||
},
|
||||
diagnostico: {
|
||||
classeAngle: 'II',
|
||||
perfil: 'Convexo',
|
||||
apinhamento: 'Moderado',
|
||||
mordida: 'Profunda',
|
||||
linhaMedia: '1MM PARA ESQUERDA',
|
||||
},
|
||||
dataInicio: '2024-08-10',
|
||||
tipoAparelho: 'AUTOLIGADO METÁLICO',
|
||||
extracoes: 'NÃO',
|
||||
sequenciaFios: '0.12 → 0.14 → 0.16 → 0.18 → 0.20x0.25',
|
||||
elasticos: 'CLASSE II',
|
||||
iprPlanejado: 'NÃO',
|
||||
tempoEstimado: '18–24 MESES',
|
||||
valor: 4500,
|
||||
formaPagamento: 'PARCELADO 24X',
|
||||
faseAtual: 'NIVELAMENTO',
|
||||
fioAtual: '0.16',
|
||||
classificacao: 'CLASSE II',
|
||||
cooperacao: 'Média',
|
||||
percentualConcluido: 35,
|
||||
ultimaConsulta: '2026-01-14',
|
||||
proximaConsulta: '2026-02-14',
|
||||
mesesRestantes: 14,
|
||||
status: 'Em Tratamento',
|
||||
evolucoes: [
|
||||
{
|
||||
id: 'ev1',
|
||||
data: '2024-08-10',
|
||||
tipo: 'Colagem',
|
||||
procedimento: 'COLAGEM APARELHO SUPERIOR E INFERIOR',
|
||||
proximoRetorno: '2024-09-10',
|
||||
fio: '0.12',
|
||||
higiene: 8,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev2',
|
||||
data: '2024-09-10',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'TROCA DE FIO SUPERIOR 0.14',
|
||||
proximoRetorno: '2024-10-10',
|
||||
fio: '0.14',
|
||||
higiene: 7,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev3',
|
||||
data: '2024-10-10',
|
||||
tipo: 'Troca de fio',
|
||||
procedimento: 'TROCA DE FIO SUPERIOR 0.16 + ELÁSTICO CLASSE II',
|
||||
proximoRetorno: '2024-11-10',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
higiene: 6,
|
||||
cooperacao: 'Média',
|
||||
},
|
||||
{
|
||||
id: 'ev4',
|
||||
data: '2025-11-10',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'MANUTENÇÃO + PROFILAXIA',
|
||||
proximoRetorno: '2025-12-10',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
procedimentos: ['Profilaxia'],
|
||||
higiene: 5,
|
||||
cooperacao: 'Média',
|
||||
observacoes: 'HIGIENE ABAIXO DO IDEAL. ORIENTAÇÕES REFORÇADAS.',
|
||||
},
|
||||
{
|
||||
id: 'ev5',
|
||||
data: '2026-01-14',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'MANUTENÇÃO CORRENTE ELÁSTICA',
|
||||
proximoRetorno: '2026-02-14',
|
||||
fio: '0.16',
|
||||
elastico: 'CLASSE II',
|
||||
higiene: 6,
|
||||
cooperacao: 'Média',
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ tipo: 'warning', mensagem: 'HIGIENE ABAIXO DO IDEAL' },
|
||||
{ tipo: 'info', mensagem: 'COOPERAÇÃO MÉDIA NAS ÚLTIMAS 3 VISITAS' },
|
||||
],
|
||||
fotos: [
|
||||
{ url: '', tipo: 'inicio', data: '2024-08-10' },
|
||||
{ url: '', tipo: 'atual', data: '2026-01-14' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
pacienteId: '2',
|
||||
pacienteNome: 'CARLOS EDUARDO MENDES',
|
||||
dataNascimento: '1990-07-22',
|
||||
tipoPaciente: 'Adulto',
|
||||
triagem: {
|
||||
queixaPrincipal: 'MORDIDA CRUZADA ANTERIOR',
|
||||
historicoMedico: 'HIPERTENSÃO CONTROLADA',
|
||||
medicacao: 'LOSARTANA 50MG',
|
||||
alergias: 'DIPIRONA',
|
||||
respiracaoBucal: false,
|
||||
habitos: 'NENHUM',
|
||||
periodonto: 'RECESSÃO GENGIVAL LEVE EM 31-41',
|
||||
implantes: 'NENHUM',
|
||||
recessoes: '31 E 41 - CLASSE I MILLER',
|
||||
mobilidade: 'NENHUMA',
|
||||
},
|
||||
diagnostico: {
|
||||
classeAngle: 'III',
|
||||
perfil: 'Côncavo',
|
||||
apinhamento: 'Severo',
|
||||
mordida: 'Cruzada',
|
||||
linhaMedia: '3MM PARA DIREITA',
|
||||
},
|
||||
dataInicio: '2024-03-05',
|
||||
tipoAparelho: 'AUTOLIGADO CERÂMICO',
|
||||
extracoes: '1º PRÉ-MOLARES SUPERIORES (14 E 24)',
|
||||
sequenciaFios: '0.14 → 0.16 → 0.18 → 0.19x0.25 → 0.21x0.25',
|
||||
elasticos: 'CLASSE III',
|
||||
iprPlanejado: 'SIM - INFERIOR ANTERIOR',
|
||||
tempoEstimado: '24–30 MESES',
|
||||
valor: 7200,
|
||||
formaPagamento: 'PARCELADO 30X',
|
||||
faseAtual: 'FECHAMENTO DE ESPAÇOS',
|
||||
fioAtual: '0.19x0.25',
|
||||
classificacao: 'CLASSE III',
|
||||
cooperacao: 'Boa',
|
||||
percentualConcluido: 55,
|
||||
ultimaConsulta: '2026-02-05',
|
||||
proximaConsulta: '2026-03-05',
|
||||
mesesRestantes: 10,
|
||||
status: 'Em Tratamento',
|
||||
evolucoes: [
|
||||
{
|
||||
id: 'ev6',
|
||||
data: '2024-03-05',
|
||||
tipo: 'Colagem',
|
||||
procedimento: 'COLAGEM APARELHO CERÂMICO SUP/INF + EXTRAÇÕES 14 E 24',
|
||||
proximoRetorno: '2024-04-05',
|
||||
fio: '0.14',
|
||||
higiene: 9,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev7',
|
||||
data: '2025-06-05',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'IPR INFERIOR ANTERIOR + MOLA FECHAMENTO',
|
||||
proximoRetorno: '2025-07-05',
|
||||
fio: '0.19x0.25',
|
||||
procedimentos: ['IPR'],
|
||||
higiene: 8,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
{
|
||||
id: 'ev8',
|
||||
data: '2026-02-05',
|
||||
tipo: 'Manutenção',
|
||||
procedimento: 'ATIVAÇÃO MOLA + ELÁSTICO CLASSE III',
|
||||
proximoRetorno: '2026-03-05',
|
||||
fio: '0.19x0.25',
|
||||
elastico: 'CLASSE III',
|
||||
higiene: 9,
|
||||
cooperacao: 'Boa',
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ tipo: 'danger', mensagem: 'COMPLEXIDADE ALTA - APINHAMENTO SEVERO' },
|
||||
{ tipo: 'warning', mensagem: 'RISCO PERIODONTAL - RECESSÃO CLASSE I' },
|
||||
],
|
||||
fotos: [
|
||||
{ url: '', tipo: 'inicio', data: '2024-03-05' },
|
||||
{ url: '', tipo: 'atual', data: '2026-02-05' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
saveEvolucaoOrto: async (tratamentoId: string, evolucao: Partial<EvolucaoOrto>): Promise<EvolucaoOrto> => {
|
||||
const newEv: EvolucaoOrto = {
|
||||
id: 'ev_' + Math.random().toString(36).substring(2, 8),
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
tipo: evolucao.tipo || 'Manutenção',
|
||||
procedimento: evolucao.procedimento || '',
|
||||
proximoRetorno: evolucao.proximoRetorno || '',
|
||||
fio: evolucao.fio,
|
||||
elastico: evolucao.elastico,
|
||||
procedimentos: evolucao.procedimentos,
|
||||
higiene: evolucao.higiene,
|
||||
cooperacao: evolucao.cooperacao,
|
||||
observacoes: evolucao.observacoes,
|
||||
};
|
||||
console.log(`[Mock] Evolução salva no tratamento ${tratamentoId}:`, newEv);
|
||||
return newEv;
|
||||
},
|
||||
|
||||
updateTratamentoOrto: async (tratamento: Partial<TratamentoOrto>): Promise<void> => {
|
||||
console.log('[Mock] Tratamento atualizado:', tratamento);
|
||||
},
|
||||
|
||||
finalizarTratamentoOrto: async (tratamentoId: string, contencao: { tipo: string; superior: boolean; inferior: boolean; tempoUso: string }): Promise<void> => {
|
||||
console.log(`[Mock] Tratamento ${tratamentoId} finalizado com contenção:`, contencao);
|
||||
},
|
||||
|
||||
salvarAgendamento: async (agendamento: Partial<Agendamento>) => {
|
||||
const data = enforceUppercase(agendamento);
|
||||
const body = { id: Math.random().toString(), ...data };
|
||||
const res = await fetch(`${API_URL}/agendamentos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
atualizarAgendamento: async (id: string, agendamento: Partial<Agendamento>) => {
|
||||
const data = enforceUppercase(agendamento);
|
||||
const res = await fetch(`${API_URL}/agendamentos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
excluirAgendamento: async (id: string) => {
|
||||
await fetch(`${API_URL}/agendamentos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- LEAD & NOTIFICATION METHODS ---
|
||||
|
||||
saveLead: async (lead: Partial<Lead>) => {
|
||||
const newLead: Lead = {
|
||||
id: Math.random().toString(),
|
||||
nome: toUpper(lead.nome),
|
||||
sobrenome: toUpper(lead.sobrenome),
|
||||
cpf: lead.cpf || '',
|
||||
whatsapp: lead.whatsapp || '',
|
||||
tipoInteresse: lead.tipoInteresse || 'Particular',
|
||||
dataCadastro: new Date().toISOString(),
|
||||
status: 'Novo'
|
||||
};
|
||||
await fetch(`${API_URL}/leads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newLead)
|
||||
});
|
||||
return newLead;
|
||||
},
|
||||
|
||||
getLeads: async (): Promise<Lead[]> => {
|
||||
const res = await fetch(`${API_URL}/leads`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getNotifications: async (): Promise<Notificacao[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/notificacoes`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
markNotificationRead: async (id: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/${id}/read`, { method: 'PUT' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao marcar como lida:", err);
|
||||
}
|
||||
},
|
||||
|
||||
markAllNotificationsRead: async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/read-all`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao marcar todas como lidas:", err);
|
||||
}
|
||||
},
|
||||
|
||||
deleteNotification: async (id: string) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/notificacoes/${id}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error("Erro ao excluir notificação:", err);
|
||||
}
|
||||
},
|
||||
|
||||
getSettings: async (): Promise<Settings> => {
|
||||
const res = await fetch(`${API_URL}/settings`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
saveSettings: async (settings: Settings) => {
|
||||
const res = await fetch(`${API_URL}/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
getGoogleEvents: async (): Promise<any[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/google/events`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,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');
|
||||
@@ -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');
|
||||
@@ -0,0 +1,31 @@
|
||||
const apiKey = 'AIzaSyBOCClHTZU9e38VLDljXR-O4QVtk1gCZ1k';
|
||||
const calendars = {
|
||||
'MURILO AMORIM': 'muriloamorim791@gmail.com',
|
||||
'RUI CESAR VARGAS': 'ruibto@gmail.com'
|
||||
};
|
||||
|
||||
async function testGoogleApi() {
|
||||
const timeMin = new Date().toISOString();
|
||||
|
||||
for (const [name, id] of Object.entries(calendars)) {
|
||||
console.log(`Checking ${name} (${id})...`);
|
||||
try {
|
||||
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(id)}/events?key=${apiKey}&timeMin=${timeMin}&singleEvents=true&orderBy=startTime`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.log(` ERROR: ${data.error.message}`);
|
||||
if (data.error.message === 'Not Found') {
|
||||
console.log(` HINT: The calendar might not be PUBLIC or the ID is wrong.`);
|
||||
}
|
||||
} else {
|
||||
console.log(` SUCCESS: Found ${data.items ? data.items.length : 0} events.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` FETCH FAILED: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testGoogleApi();
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from 'http';
|
||||
|
||||
const testLogin = () => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3005,
|
||||
path: '/api/login',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`STATUS: ${res.statusCode}`);
|
||||
res.on('data', (d) => {
|
||||
process.stdout.write(d);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error(`problem with request: ${e.message}`);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify({ email: 'admin@scoreodonto.com', password: 'admin' }));
|
||||
req.end();
|
||||
};
|
||||
|
||||
testLogin();
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
export interface Paciente {
|
||||
id: string;
|
||||
nome: string;
|
||||
cpf: string;
|
||||
telefone: string;
|
||||
email: string;
|
||||
ultimoTratamento?: string;
|
||||
convenio?: string;
|
||||
dataNascimento?: string;
|
||||
}
|
||||
|
||||
export interface Dentista {
|
||||
id: string;
|
||||
clinica_id?: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
telefone: string;
|
||||
cro?: string;
|
||||
cro_uf?: string;
|
||||
celular?: string;
|
||||
corAgenda: string;
|
||||
especialidade: string;
|
||||
ativo: boolean;
|
||||
ordem: number;
|
||||
}
|
||||
|
||||
export interface DentistaHorario {
|
||||
id: string;
|
||||
dentista_id: string;
|
||||
clinica_id: string;
|
||||
dia_semana: number;
|
||||
hora_inicio: string;
|
||||
hora_fim: string;
|
||||
ativo: boolean;
|
||||
}
|
||||
|
||||
export interface Agendamento {
|
||||
id: string;
|
||||
pacienteNome: string;
|
||||
dentistaId: string;
|
||||
start: string; // ISO String
|
||||
end: string; // ISO String
|
||||
status: 'Confirmado' | 'Pendente' | 'Concluido' | 'Faltou';
|
||||
procedimento: string;
|
||||
observacoes?: string;
|
||||
}
|
||||
|
||||
// ─── ORTODONTIA ───
|
||||
|
||||
export type TipoPacienteOrto = 'Infantil' | 'Adolescente' | 'Adulto';
|
||||
export type TipoEventoOrto = 'Manutenção' | 'Troca de fio' | 'Colagem' | 'Emergência' | 'Falta' | 'Finalização';
|
||||
export type CooperacaoOrto = 'Boa' | 'Média' | 'Ruim';
|
||||
export type StatusOrto = 'Em Tratamento' | 'Contenção' | 'Finalizado';
|
||||
|
||||
export interface TriagemOrto {
|
||||
queixaPrincipal?: string;
|
||||
historicoMedico?: string;
|
||||
medicacao?: string;
|
||||
alergias?: string;
|
||||
respiracaoBucal?: boolean;
|
||||
habitos?: string; // dedo, chupeta, bruxismo
|
||||
// campos extras adulto
|
||||
periodonto?: string;
|
||||
implantes?: string;
|
||||
recessoes?: string;
|
||||
mobilidade?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticoOrto {
|
||||
classeAngle?: 'I' | 'II' | 'III';
|
||||
perfil?: 'Reto' | 'Convexo' | 'Côncavo';
|
||||
apinhamento?: 'Leve' | 'Moderado' | 'Severo';
|
||||
mordida?: 'Aberta' | 'Profunda' | 'Cruzada' | 'Normal';
|
||||
linhaMedia?: string; // ex: "2mm para esquerda"
|
||||
}
|
||||
|
||||
export interface OrthoFlag {
|
||||
tipo: 'danger' | 'warning' | 'info';
|
||||
mensagem: string;
|
||||
}
|
||||
|
||||
export interface OrthoFoto {
|
||||
url: string;
|
||||
tipo: 'inicio' | 'atual' | 'intraoral' | 'extraoral' | 'raio-x';
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface EvolucaoOrto {
|
||||
id: string;
|
||||
data: string;
|
||||
tipo: TipoEventoOrto;
|
||||
procedimento: string; // resumo textual (compat legado)
|
||||
proximoRetorno: string;
|
||||
fio?: string;
|
||||
elastico?: string;
|
||||
procedimentos?: string[]; // IPR, Recolagem, Torque, Profilaxia
|
||||
higiene?: number; // 1-10
|
||||
cooperacao?: CooperacaoOrto;
|
||||
observacoes?: string;
|
||||
}
|
||||
|
||||
export interface TratamentoOrto {
|
||||
id: string;
|
||||
pacienteId: string;
|
||||
pacienteNome: string;
|
||||
dataNascimento?: string;
|
||||
responsavel?: string;
|
||||
tipoPaciente?: TipoPacienteOrto;
|
||||
// Triagem & Diagnóstico
|
||||
triagem?: TriagemOrto;
|
||||
diagnostico?: DiagnosticoOrto;
|
||||
// Plano de tratamento
|
||||
dataInicio: string;
|
||||
tipoAparelho: string;
|
||||
extracoes?: string;
|
||||
sequenciaFios?: string;
|
||||
elasticos?: string;
|
||||
iprPlanejado?: string;
|
||||
tempoEstimado?: string;
|
||||
valor?: number;
|
||||
formaPagamento?: string;
|
||||
// Dashboard
|
||||
faseAtual?: string;
|
||||
fioAtual?: string;
|
||||
classificacao?: string;
|
||||
cooperacao?: CooperacaoOrto;
|
||||
percentualConcluido?: number;
|
||||
ultimaConsulta?: string;
|
||||
proximaConsulta?: string;
|
||||
mesesRestantes?: number;
|
||||
// Status & Contenção
|
||||
status: StatusOrto;
|
||||
contencaoTipo?: string;
|
||||
contencaoSuperior?: boolean;
|
||||
contencaoInferior?: boolean;
|
||||
tempoUso?: string;
|
||||
// Coleções
|
||||
evolucoes: EvolucaoOrto[];
|
||||
flags?: OrthoFlag[];
|
||||
fotos?: OrthoFoto[];
|
||||
}
|
||||
|
||||
export interface FinanceiroItem {
|
||||
id: string;
|
||||
pacienteNome: string;
|
||||
descricao: string;
|
||||
valor: number;
|
||||
dataVencimento: string;
|
||||
status: 'Pago' | 'Pendente' | 'Atrasado';
|
||||
formaPagamento: 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro';
|
||||
}
|
||||
|
||||
export interface Plano {
|
||||
id: string;
|
||||
nome: string;
|
||||
tipo: 'Convenio' | 'Particular' | 'Parceria';
|
||||
descontoPadrao: number; // Porcentagem
|
||||
corCartao: string;
|
||||
}
|
||||
|
||||
export interface Especialidade {
|
||||
id: string;
|
||||
nome: string;
|
||||
descricao: string;
|
||||
ordem: number;
|
||||
}
|
||||
|
||||
export interface ProcedimentoValorPlano {
|
||||
planoId: string;
|
||||
planoNome: string;
|
||||
valor: number;
|
||||
codigoPlano: string; // Código específico na operadora
|
||||
}
|
||||
|
||||
export interface Procedimento {
|
||||
id: string;
|
||||
nome: string;
|
||||
codigo?: string;
|
||||
codigoInterno?: string;
|
||||
descricao?: string;
|
||||
especialidadeId: string;
|
||||
especialidadeNome: string;
|
||||
valorParticular: number;
|
||||
categoria: 'Particular' | 'Plano' | 'Convênio' | 'Outros';
|
||||
ordem: number;
|
||||
tipo_regiao: 'DENTE' | 'ARCO' | 'GERAL';
|
||||
exige_face: boolean;
|
||||
valoresPlanos?: ProcedimentoValorPlano[];
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: string;
|
||||
nome: string;
|
||||
sobrenome: string;
|
||||
cpf: string;
|
||||
whatsapp: string;
|
||||
tipoInteresse: 'Particular' | 'Plano';
|
||||
dataCadastro: string;
|
||||
status: 'Novo' | 'Convertido' | 'Arquivado';
|
||||
}
|
||||
|
||||
export interface Notificacao {
|
||||
id: string;
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
tipo: 'lead' | 'sistema' | 'agenda' | 'financeiro';
|
||||
lida: boolean;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
clinicEmail?: string;
|
||||
clinicCalendarId?: string;
|
||||
googleApiKey?: string;
|
||||
googleCalendarIds?: Record<string, string>; // dentistId -> calendarId
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity } from 'lucide-react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Dentista, Especialidade, Plano, Procedimento, ProcedimentoValorPlano, DentistaHorario } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
|
||||
|
||||
const AdminSection: React.FC<{ title: string; buttonLabel: string; onButtonClick: () => void; children: React.ReactNode }> =
|
||||
({ title, buttonLabel, onButtonClick, children }) => (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title}>
|
||||
<button
|
||||
onClick={onButtonClick}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 font-bold text-xs uppercase transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={18} /> {buttonLabel}
|
||||
</button>
|
||||
</PageHeader>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DentistHoursModal: React.FC<{ dentist: Dentista; clinicaId: string; onClose: () => void }> = ({ dentist, clinicaId, onClose }) => {
|
||||
const { data: horarios, refresh } = useHybridBackend<DentistaHorario[]>(() => HybridBackend.getHorariosByDentista(dentist.id, clinicaId));
|
||||
const toast = useToast();
|
||||
const dias = ['DOMINGO', 'SEGUNDA', 'TERÇA', 'QUARTA', 'QUINTA', 'SEXTA', 'SÁBADO'];
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
await HybridBackend.saveHorario({
|
||||
dentista_id: dentist.id,
|
||||
clinica_id: clinicaId,
|
||||
dia_semana: parseInt(fd.get('dia') as string),
|
||||
hora_inicio: fd.get('inicio') as string,
|
||||
hora_fim: fd.get('fim') as string,
|
||||
ativo: true
|
||||
});
|
||||
toast.success("HORÁRIO ADICIONADO!");
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-gray-900 uppercase leading-tight">ESCALA DE ATENDIMENTO</h3>
|
||||
<p className="text-[10px] text-blue-600 font-bold uppercase">{dentist.nome}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-xl transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-3 bg-blue-50/50 p-4 rounded-xl border border-blue-100">
|
||||
<select name="dia" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold uppercase" required>
|
||||
{dias.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
<input name="inicio" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
||||
<input name="fim" type="time" className="bg-white border-blue-200 border rounded-lg p-2 text-xs font-bold" required />
|
||||
<button type="submit" className="bg-blue-600 text-white rounded-lg font-black text-[10px] uppercase hover:bg-blue-700 transition-all flex items-center justify-center gap-2">
|
||||
<Plus size={14} /> ADICIONAR
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||
{horarios?.map(h => (
|
||||
<div key={h.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-100 hover:border-blue-200 transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm text-blue-600 border border-gray-100 font-black text-[10px]">
|
||||
{dias[h.dia_semana].substring(0, 3)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-black text-gray-700">
|
||||
<Clock size={14} className="text-gray-400" />
|
||||
<span>{h.hora_inicio.substring(0, 5)}</span>
|
||||
<ArrowRight size={12} className="text-gray-300" />
|
||||
<span>{h.hora_fim.substring(0, 5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => { await HybridBackend.deleteHorario(h.id); refresh(); toast.success("REMOVIDO!"); }}
|
||||
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- DENTISTAS ---
|
||||
export const DentistasView: React.FC = () => {
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const { data: dentistas, isLoading, error, refresh } = useHybridBackend<Dentista[]>(() => HybridBackend.getDentistas(activeWorkspace?.id));
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingDentista, setEditingDentista] = useState<Partial<Dentista> | null>(null);
|
||||
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
|
||||
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchGoogleStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/auth/google/status');
|
||||
const data = await response.json();
|
||||
setConnectedAccounts(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar status do Google:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingDentista && editingDentista.id) {
|
||||
await HybridBackend.updateDentista({ ...editingDentista, ...data } as Dentista);
|
||||
toast.success("DENTISTA ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.saveDentista({ ...data, ativo: true } as any);
|
||||
toast.success("DENTISTA CADASTRADO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR DENTISTA.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE DENTISTA?")) {
|
||||
await HybridBackend.deleteDentista(id);
|
||||
toast.success("Dentista excluído.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const handleInviteDentist = async (d: Dentista) => {
|
||||
try {
|
||||
const activeW = HybridBackend.getActiveWorkspace();
|
||||
const res = await fetch('http://localhost:3005/api/dentistas/magic-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clinicaId: activeW.id, dentistName: d.nome })
|
||||
});
|
||||
const { link } = await res.json();
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopiedId(d.id);
|
||||
toast.success("LINK MÁGICO COPIADO!");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
toast.error("ERRO AO GERAR LINK.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="DENTISTAS & ESPECIALISTAS" buttonLabel="NOVO DENTISTA" onButtonClick={() => { setEditingDentista({}); setIsModalOpen(true); }}>
|
||||
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
||||
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{dentistas?.map(d => (
|
||||
<div key={d.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
||||
<div className="p-5 flex-grow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 uppercase">{d.nome}</h3>
|
||||
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full uppercase">{d.especialidade}</span>
|
||||
</div>
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white shadow" style={{ backgroundColor: d.corAgenda }}></div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 text-xs text-gray-500">
|
||||
<p className="flex items-center gap-2 font-medium"><Mail size={14} /> {d.email}</p>
|
||||
<p className="flex items-center gap-2 font-medium"><Phone size={14} /> {d.telefone}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-gray-50 pt-4 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<GoogleConnectButton
|
||||
ownerId={d.id}
|
||||
isConnected={connectedAccounts.some(a => a.owner_id === d.id)}
|
||||
onStatusChange={fetchGoogleStatus}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInviteDentist(d)}
|
||||
title="COMPARTILHAR LINK DE CADASTRO"
|
||||
className={`p-2.5 rounded-xl border transition-all ${copiedId === d.id ? 'bg-green-50 text-green-600 border-green-200' : 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-100'}`}
|
||||
>
|
||||
{copiedId === d.id ? <Check size={18} /> : <Share2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 p-2 grid grid-cols-3 gap-1">
|
||||
<button onClick={() => { setEditingDentista(d); setIsModalOpen(true); }} className="text-[10px] uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-1"><Edit size={12} /> EDITAR</button>
|
||||
<button onClick={() => { setSelectedDentist(d); setIsHoursModalOpen(true); }} className="text-[10px] uppercase font-bold text-blue-600 hover:bg-blue-50 py-2 rounded-md flex items-center justify-center gap-1"><Clock size={12} /> ESCALA</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="text-[10px] uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-1"><Trash2 size={12} /> EXCLUIR</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingDentista?.id ? 'EDITAR' : 'NOVO'} DENTISTA</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input id="dentista-telefone" type="text" name="telefone" placeholder="TELEFONE" defaultValue={editingDentista?.telefone} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<input id="dentista-celular" type="text" name="celular" placeholder="WHATSAPP / CELULAR" defaultValue={editingDentista?.celular} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input id="dentista-cro" type="text" name="cro" placeholder="NÚMERO CRO" defaultValue={editingDentista?.cro} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<select id="dentista-cro-uf" name="cro_uf" defaultValue={editingDentista?.cro_uf || 'MS'} className="w-full border border-gray-300 rounded-lg p-2">
|
||||
<option value="MS">MS</option><option value="SP">SP</option><option value="RJ">RJ</option><option value="MG">MG</option><option value="PR">PR</option><option value="SC">SC</option>
|
||||
</select>
|
||||
</div>
|
||||
<input id="dentista-especialidade" type="text" name="especialidade" placeholder="ESPECIALIDADE PRINCIPAL" defaultValue={editingDentista?.especialidade} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<div>
|
||||
<label htmlFor="dentista-cor" className="text-xs font-bold text-gray-500 uppercase">COR NA AGENDA</label>
|
||||
<input id="dentista-cor" type="color" name="corAgenda" defaultValue={editingDentista?.corAgenda || '#3B82F6'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHoursModalOpen && selectedDentist && activeWorkspace && (
|
||||
<DentistHoursModal
|
||||
dentist={selectedDentist}
|
||||
clinicaId={activeWorkspace.id}
|
||||
onClose={() => { setIsHoursModalOpen(false); setSelectedDentist(null); }}
|
||||
/>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
// --- ESPECIALIDADES ---
|
||||
export const EspecialidadesView: React.FC = () => {
|
||||
const { data: especialidades, isLoading, error, refresh } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEsp, setEditingEsp] = useState<Partial<Especialidade> | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingEsp && editingEsp.id) {
|
||||
await HybridBackend.updateEspecialidade({ ...editingEsp, ...data } as Especialidade);
|
||||
toast.success("ESPECIALIDADE ATUALIZADA!");
|
||||
} else {
|
||||
await HybridBackend.saveEspecialidade(data as any);
|
||||
toast.success("ESPECIALIDADE SALVA!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR ESPECIALIDADE.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTA ESPECIALIDADE?")) {
|
||||
await HybridBackend.deleteEspecialidade(id);
|
||||
toast.success("Especialidade excluída.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !especialidades) return;
|
||||
|
||||
const items = Array.from(especialidades);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderEspecialidades(updatedOrders);
|
||||
toast.success("ORDEM ATUALIZADA!");
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="ESPECIALIDADES CLÍNICAS" buttonLabel="NOVA ESPECIALIDADE" onButtonClick={() => { setEditingEsp({}); setIsModalOpen(true); }}>
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left w-10"></th>
|
||||
<th className="px-6 py-3 text-left">NOME</th>
|
||||
<th className="px-6 py-3 text-left">DESCRIÇÃO</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="especialidades">
|
||||
{(provided) => (
|
||||
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
||||
{isLoading && <tr><td colSpan={4} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
||||
{error && <tr><td colSpan={4} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
||||
{especialidades?.map((esp, index) => (
|
||||
<Draggable key={esp.id} draggableId={esp.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<tr
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} transition-shadow`}
|
||||
>
|
||||
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
||||
<GripVertical size={18} />
|
||||
</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">{esp.nome}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{esp.descricao}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setEditingEsp(esp); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
||||
<button onClick={() => handleDelete(esp.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingEsp?.id ? 'EDITAR' : 'NOVA'} ESPECIALIDADE</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input id="esp-nome" name="nome" placeholder="NOME DA ESPECIALIDADE" defaultValue={editingEsp?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<textarea id="esp-desc" name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingEsp?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-24 uppercase-textarea"></textarea>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
// --- PROCEDIMENTOS ---
|
||||
export const ProcedimentosView: React.FC = () => {
|
||||
const { data: procedimentos, isLoading, error, refresh } = useHybridBackend<Procedimento[]>(HybridBackend.getProcedimentos);
|
||||
const { data: especialidades } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
||||
const { data: planos } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProcedimento, setEditingProcedimento] = useState<Partial<Procedimento> | null>(null);
|
||||
const [planValues, setPlanValues] = useState<Partial<ProcedimentoValorPlano>[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProcedimento) {
|
||||
setPlanValues(editingProcedimento.valoresPlanos || []);
|
||||
// Set initial category if new
|
||||
if (!editingProcedimento.id && !editingProcedimento.categoria) {
|
||||
setEditingProcedimento(prev => ({ ...prev, categoria: 'Particular' }));
|
||||
}
|
||||
}
|
||||
}, [editingProcedimento]);
|
||||
|
||||
const suggestInternalCode = () => {
|
||||
const nextId = (procedimentos?.length || 0) + 1;
|
||||
setEditingProcedimento(prev => ({ ...prev, codigoInterno: nextId.toString() }));
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data: any = Object.fromEntries(formData.entries());
|
||||
|
||||
const esp = especialidades?.find(es => es.id === data.especialidadeId);
|
||||
|
||||
const payload: Partial<Procedimento> = {
|
||||
id: editingProcedimento?.id,
|
||||
nome: data.nome,
|
||||
codigo: data.codigo,
|
||||
codigoInterno: data.codigoInterno,
|
||||
categoria: data.categoria,
|
||||
descricao: data.descricao,
|
||||
especialidadeId: data.especialidadeId,
|
||||
especialidadeNome: esp?.nome || '',
|
||||
valorParticular: parseFloat(data.valorParticular),
|
||||
tipo_regiao: editingProcedimento?.tipo_regiao || 'GERAL',
|
||||
exige_face: editingProcedimento?.tipo_regiao === 'DENTE' ? !!editingProcedimento?.exige_face : false,
|
||||
valoresPlanos: planValues.map(pv => ({
|
||||
planoId: pv.planoId!,
|
||||
planoNome: planos?.find(p => p.id === pv.planoId)?.nome || '',
|
||||
valor: parseFloat(pv.valor as any),
|
||||
codigoPlano: pv.codigoPlano || ''
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (payload.id) {
|
||||
await HybridBackend.updateProcedimento(payload as Procedimento);
|
||||
toast.success("PROCEDIMENTO ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.saveProcedimento(payload);
|
||||
toast.success("PROCEDIMENTO SALVO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR PROCEDIMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PROCEDIMENTO?")) {
|
||||
await HybridBackend.deleteProcedimento(id);
|
||||
toast.success("Procedimento excluído.");
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPlanValue = () => setPlanValues(prev => [...prev, { planoId: '', valor: 0, codigoPlano: '' }]);
|
||||
const handleRemovePlanValue = (index: number) => setPlanValues(prev => prev.filter((_, i) => i !== index));
|
||||
const handlePlanValueChange = (index: number, field: 'planoId' | 'valor' | 'codigoPlano', value: string) => {
|
||||
const newValues = [...planValues];
|
||||
newValues[index] = { ...newValues[index], [field]: value };
|
||||
setPlanValues(newValues);
|
||||
};
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !procedimentos) return;
|
||||
|
||||
const items = Array.from(procedimentos);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderProcedimentos(updatedOrders);
|
||||
toast.success("ORDEM DOS PROCEDIMENTOS ATUALIZADA!");
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR PROCEDIMENTOS.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection title="PROCEDIMENTOS E VALORES" buttonLabel="NOVO PROCEDIMENTO" onButtonClick={() => { setEditingProcedimento({}); setIsModalOpen(true); }}>
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left w-10"></th>
|
||||
<th className="px-6 py-3 text-left">NOME</th>
|
||||
<th className="px-6 py-3 text-left">CÓDIGO (TUSS/INT)</th>
|
||||
<th className="px-6 py-3 text-left">CATEGORIA</th>
|
||||
<th className="px-6 py-3 text-left">ESPECIALIDADE</th>
|
||||
<th className="px-6 py-3 text-left">VALOR PARTICULAR</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="procedimentos">
|
||||
{(provided) => (
|
||||
<tbody {...provided.droppableProps} ref={provided.innerRef} className="divide-y divide-gray-100">
|
||||
{isLoading && <tr><td colSpan={7} className="p-6 text-center"><Loader2 className="animate-spin text-blue-500 mx-auto" /></td></tr>}
|
||||
{error && <tr><td colSpan={7} className="p-6 text-center text-red-500">{error.message}</td></tr>}
|
||||
{procedimentos?.map((proc, index) => (
|
||||
<Draggable key={proc.id} draggableId={proc.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<tr
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`${snapshot.isDragging ? 'bg-blue-50 shadow-md ring-1 ring-blue-200' : 'bg-white'} transition-shadow`}
|
||||
>
|
||||
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
|
||||
<GripVertical size={18} />
|
||||
</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">{proc.nome}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">TUSS: {proc.codigo || '---'}</span>
|
||||
<span className="text-[10px] font-bold text-blue-600 uppercase bg-blue-50 px-1.5 py-0.5 rounded w-fit">INT: {proc.codigoInterno || '---'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="text-xs font-bold px-2 py-1 rounded-full bg-gray-100 text-gray-600 uppercase">{proc.categoria}</span></td>
|
||||
<td className="px-6 py-4 text-gray-600">{proc.especialidadeNome}</td>
|
||||
<td className="px-6 py-4 font-semibold text-gray-700">R$ {Number(proc.valorParticular).toFixed(2)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setEditingProcedimento(proc); setIsModalOpen(true); }} className="p-2 text-gray-500 hover:bg-gray-100 rounded-md"><Edit size={16} /></button>
|
||||
<button onClick={() => handleDelete(proc.id)} className="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingProcedimento?.id ? 'EDITAR' : 'NOVO'} PROCEDIMENTO</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto pb-4">
|
||||
<input name="nome" placeholder="NOME DO PROCEDIMENTO" defaultValue={editingProcedimento?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CATEGORIA</label>
|
||||
<select
|
||||
name="categoria"
|
||||
value={editingProcedimento?.categoria || 'Particular'}
|
||||
onChange={(e) => {
|
||||
const cat = e.target.value as any;
|
||||
setEditingProcedimento(prev => ({ ...prev, categoria: cat }));
|
||||
if (cat === 'Particular' && !editingProcedimento?.codigoInterno) {
|
||||
suggestInternalCode();
|
||||
}
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select"
|
||||
required
|
||||
>
|
||||
<option value="Particular">Particular</option>
|
||||
<option value="Plano">Plano</option>
|
||||
<option value="Convênio">Convênio</option>
|
||||
<option value="Outros">Outros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">CÓDIGO TUSS</label>
|
||||
<input
|
||||
name="codigo"
|
||||
placeholder={editingProcedimento?.categoria === 'Plano' ? "00000000" : "OPCIONAL"}
|
||||
defaultValue={editingProcedimento?.codigo}
|
||||
className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria !== 'Plano' ? 'bg-gray-50' : ''}`}
|
||||
required={editingProcedimento?.categoria === 'Plano'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase flex justify-between">
|
||||
CÓDIGO INTERNO
|
||||
{editingProcedimento?.categoria !== 'Particular' && (
|
||||
<button type="button" onClick={suggestInternalCode} className="text-blue-600 hover:text-blue-800">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
name="codigoInterno"
|
||||
placeholder={editingProcedimento?.categoria === 'Particular' ? "AUTO (CONTAGEM + 1)" : "EX: 101"}
|
||||
value={editingProcedimento?.codigoInterno || ''}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, codigoInterno: e.target.value }))}
|
||||
className={`w-full border border-gray-300 rounded-lg p-2 uppercase-input ${editingProcedimento?.categoria === 'Particular' ? 'bg-gray-50 text-blue-600 font-bold' : ''}`}
|
||||
required={editingProcedimento?.categoria !== 'Particular'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<textarea name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingProcedimento?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-20 uppercase-textarea"></textarea>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">ESPECIALIDADE</label>
|
||||
<select name="especialidadeId" defaultValue={editingProcedimento?.especialidadeId} className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select" required>
|
||||
<option value="">SELECIONE UMA ESPECIALIDADE</option>
|
||||
{especialidades?.map(e => <option key={e.id} value={e.id}>{e.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">VALOR PARTICULAR (R$)</label>
|
||||
<input type="number" step="0.01" name="valorParticular" placeholder="0.00" defaultValue={editingProcedimento?.valorParticular} className="w-full border border-gray-300 rounded-lg p-2" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl border border-blue-100 space-y-4">
|
||||
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-widest flex items-center gap-2">
|
||||
<Activity size={12} /> REGRA DO ODONTOGRAMA (GTO)
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-gray-700 uppercase">EXIGÊNCIA DE REGIÃO NO LANÇAMENTO</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'GERAL', label: 'PROCED. GERAL', desc: 'SEM ODONTOGRAMA' },
|
||||
{ id: 'DENTE', label: 'DENTE ESPECÍFICO', desc: 'LIBERA NÚMEROS' },
|
||||
{ id: 'ARCO', label: 'ARCO COMPLETO', desc: 'LIBERA AI / AS' }
|
||||
].map((opt) => (
|
||||
<label
|
||||
key={opt.id}
|
||||
className={`
|
||||
relative flex flex-col p-3 rounded-lg border-2 cursor-pointer transition-all
|
||||
${(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id
|
||||
? 'border-blue-600 bg-blue-50 ring-2 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tipo_regiao"
|
||||
value={opt.id}
|
||||
checked={(editingProcedimento?.tipo_regiao || 'GERAL') === opt.id}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, tipo_regiao: e.target.value as any }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-[10px] font-black uppercase text-gray-900 leading-tight">{opt.label}</span>
|
||||
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">{opt.desc}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(editingProcedimento?.tipo_regiao === 'DENTE') && (
|
||||
<div className="pt-2 animate-in slide-in-from-top-2 duration-200">
|
||||
<label className="flex items-center gap-3 p-3 bg-white rounded-lg border border-blue-200 cursor-pointer hover:bg-blue-50/50 transition-colors">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="exige_face"
|
||||
checked={!!editingProcedimento?.exige_face}
|
||||
onChange={(e) => setEditingProcedimento(prev => ({ ...prev, exige_face: e.target.checked }))}
|
||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-black text-gray-800 uppercase">Exigir preenchimento de Face?</span>
|
||||
<span className="text-[9px] font-bold text-gray-400 uppercase">(O, M, D, V, L, P) será obrigatório na GTO</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<h4 className="font-bold text-gray-700 uppercase mb-2">VALORES PARA PLANOS / CONVÊNIOS</h4>
|
||||
<div className="space-y-2">
|
||||
{planValues.map((pv, index) => (
|
||||
<div key={index} className="flex flex-col md:flex-row items-center gap-2 bg-gray-50 p-2 rounded-lg border">
|
||||
<select value={pv.planoId} onChange={(e) => handlePlanValueChange(index, 'planoId', e.target.value)} className="flex-1 border border-gray-300 rounded-lg p-2 bg-white uppercase-select">
|
||||
<option value="">SELECIONE UM PLANO</option>
|
||||
{planos?.filter(p => p.tipo !== 'Particular').map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
<input type="number" step="0.01" placeholder="VALOR (R$)" value={pv.valor} onChange={(e) => handlePlanValueChange(index, 'valor', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2" />
|
||||
<input type="text" placeholder="CÓD. PLANO" value={pv.codigoPlano} onChange={(e) => handlePlanValueChange(index, 'codigoPlano', e.target.value)} className="w-32 border border-gray-300 rounded-lg p-2 uppercase-input" />
|
||||
<button type="button" onClick={() => handleRemovePlanValue(index)} className="p-2 text-red-500 hover:bg-red-100 rounded-md"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={handleAddPlanValue} className="mt-2 text-xs font-bold text-blue-600 hover:underline uppercase flex items-center gap-1"><Plus size={14} /> ADICIONAR VALOR DE PLANO</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex-shrink-0">
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-4 rounded-xl font-black hover:bg-green-700 uppercase text-xs shadow-lg shadow-green-100 transition-all active:scale-[0.98]">
|
||||
SALVAR PROCEDIMENTO
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- PLANOS ---
|
||||
export const PlanosView: React.FC = () => {
|
||||
const { data: planos, isLoading, error, refresh } = useHybridBackend<Plano[]>(HybridBackend.getPlanos);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingPlano, setEditingPlano] = useState<Partial<Plano> | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsModalOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
if (editingPlano && editingPlano.id) {
|
||||
await HybridBackend.updatePlano({ ...editingPlano, ...data } as Plano);
|
||||
toast.success("PLANO ATUALIZADO!");
|
||||
} else {
|
||||
await HybridBackend.savePlano(data as any);
|
||||
toast.success("PLANO SALVO!");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR PLANO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm("TEM CERTEZA QUE DESEJA EXCLUIR ESTE PLANO?")) {
|
||||
await HybridBackend.deletePlano(id);
|
||||
toast.success("Plano excluído.");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSection title="PLANOS E CONVÊNIOS" buttonLabel="NOVO PLANO" onButtonClick={() => { setEditingPlano({}); setIsModalOpen(true); }}>
|
||||
{isLoading && <Loader2 className="animate-spin text-blue-500" />}
|
||||
{error && <p className="text-red-500">Erro: {error.message}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{planos?.map(p => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col overflow-hidden" style={{ borderTop: `4px solid ${p.corCartao}` }}>
|
||||
<div className="p-5 flex-grow">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-gray-900 uppercase">{p.nome}</h3>
|
||||
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full uppercase">{p.tipo}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">DESCONTO PADRÃO: <span className="font-bold">{p.descontoPadrao}%</span></p>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 p-2 flex gap-2 bg-gray-50/50">
|
||||
<button onClick={() => { setEditingPlano(p); setIsModalOpen(true); }} className="flex-1 text-xs uppercase font-bold text-gray-600 hover:bg-gray-100 py-2 rounded-md flex items-center justify-center gap-2"><Edit size={14} /> EDITAR</button>
|
||||
<button onClick={() => handleDelete(p.id)} className="flex-1 text-xs uppercase font-bold text-red-500 hover:bg-red-50 py-2 rounded-md flex items-center justify-center gap-2"><Trash2 size={14} /> EXCLUIR</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase">{editingPlano?.id ? 'EDITAR' : 'NOVO'} PLANO</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input name="nome" placeholder="NOME DO PLANO/CONVÊNIO" defaultValue={editingPlano?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<select name="tipo" defaultValue={editingPlano?.tipo || 'Convenio'} className="w-full border border-gray-300 rounded-lg p-2 bg-white uppercase-select" required>
|
||||
<option>Convenio</option>
|
||||
<option>Particular</option>
|
||||
<option>Parceria</option>
|
||||
</select>
|
||||
<input type="number" name="descontoPadrao" placeholder="DESCONTO PADRÃO (%)" defaultValue={editingPlano?.descontoPadrao} className="w-full border border-gray-300 rounded-lg p-2" />
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">COR DO CARTÃO</label>
|
||||
<input type="color" name="corCartao" defaultValue={editingPlano?.corCartao || '#1f2937'} className="w-full h-10 border border-gray-300 rounded-lg p-1" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- SYNC ---
|
||||
export const SyncView: React.FC = () => {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const startSync = async () => {
|
||||
setSyncing(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
await HybridBackend.syncGoogleSheetsToMysql((log) => {
|
||||
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${log}`]);
|
||||
});
|
||||
toast.success("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!");
|
||||
} catch {
|
||||
toast.error("FALHA NA SINCRONIZAÇÃO.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<PageHeader title="IMPORTAÇÃO E SINCRONIZAÇÃO" description="GOOGLE SHEETS ➔ MYSQL (SIS_ODONTO)">
|
||||
<button
|
||||
onClick={startSync}
|
||||
disabled={syncing}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-bold uppercase transition-colors shadow-sm ${syncing ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
|
||||
>
|
||||
<RefreshCw size={18} className={syncing ? 'animate-spin' : ''} />
|
||||
{syncing ? 'SINCRONIZANDO...' : 'INICIAR IMPORTAÇÃO'}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm text-green-400 h-96 overflow-y-auto shadow-inner uppercase">
|
||||
{logs.length === 0 && <span className="text-gray-500 opacity-50">AGUARDANDO INÍCIO DO PROCESSO...</span>}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="mb-1 border-l-2 border-green-700 pl-2 animate-in fade-in">{log}</div>
|
||||
))}
|
||||
{syncing && <div className="animate-pulse">_</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 border-t border-yellow-100 flex items-start gap-3">
|
||||
<div className="text-yellow-600 mt-0.5"><Database size={18} /></div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-yellow-800 uppercase">NOTA DE SEGURANÇA</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1 uppercase font-medium">
|
||||
ESTA AÇÃO LÊ TODOS OS DADOS DA PLANILHA MESTRE E ATUALIZA O BANCO DE DADOS MYSQL LOCAL.
|
||||
O GOOGLE SHEETS PERMANECE COMO FONTE SEGURA DE BACKUP. CONFLITOS SERÃO RESOLVIDOS PRIORIZANDO A DATA DE MODIFICAÇÃO MAIS RECENTE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// --- Reusable Debounce Hook ---
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// --- Advanced Appointment Modal Component ---
|
||||
const AppointmentModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
dentists: Dentista[] | null;
|
||||
initialDate?: string;
|
||||
initialAppointment?: Agendamento | null;
|
||||
}> = ({ isOpen, onClose, onSave, dentists, initialDate, initialAppointment }) => {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [duration, setDuration] = useState(15);
|
||||
const [selectedDate, setSelectedDate] = useState(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]);
|
||||
const [selectedTime, setSelectedTime] = useState(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : '');
|
||||
const [selectedDentistId, setSelectedDentistId] = useState<string | undefined>(dentists?.[0]?.id);
|
||||
const [procedimento, setProcedimento] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||
|
||||
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedSearchTerm), [debouncedSearchTerm]);
|
||||
const { data: searchResults, isLoading: isSearching } = useHybridBackend<Paciente[]>(fetcher, [debouncedSearchTerm]);
|
||||
|
||||
const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const { data: allHorarios } = useHybridBackend<DentistaHorario[]>(() =>
|
||||
selectedDentistId && activeWorkspace ? HybridBackend.getHorariosByDentista(selectedDentistId, activeWorkspace.id) : Promise.resolve([]),
|
||||
[selectedDentistId, activeWorkspace?.id]
|
||||
);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialAppointment) {
|
||||
setSelectedPatient({ nome: initialAppointment.pacienteNome, cpf: '', id: 'temp' } as any);
|
||||
const startDate = new Date(initialAppointment.start);
|
||||
const endDate = new Date(initialAppointment.end);
|
||||
setSelectedDate(startDate.toISOString().split('T')[0]);
|
||||
setSelectedTime(startDate.toTimeString().substring(0, 5));
|
||||
setDuration((endDate.getTime() - startDate.getTime()) / 60000);
|
||||
setSelectedDentistId(initialAppointment.dentistaId);
|
||||
setProcedimento(initialAppointment.procedimento);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
setSearchTerm('');
|
||||
setSelectedDate(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : '');
|
||||
setProcedimento('');
|
||||
setDuration(15);
|
||||
if (dentists && dentists.length > 0) {
|
||||
setSelectedDentistId(dentists[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialDate, dentists, initialAppointment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const selectedDentist = dentists?.find(d => d.id === selectedDentistId);
|
||||
|
||||
const timeSlots = useCallback(() => {
|
||||
const slots: string[] = [];
|
||||
if (!selectedDentistId || !selectedDate) return slots;
|
||||
|
||||
const existingAppointments = agendamentos?.filter(ag =>
|
||||
ag.dentistaId === selectedDentistId &&
|
||||
new Date(ag.start).toISOString().split('T')[0] === selectedDate &&
|
||||
ag.id !== initialAppointment?.id
|
||||
).map(ag => ({
|
||||
start: new Date(ag.start).getHours() * 60 + new Date(ag.start).getMinutes(),
|
||||
end: new Date(ag.end).getHours() * 60 + new Date(ag.end).getMinutes(),
|
||||
})) || [];
|
||||
|
||||
const dayOfWeek = new Date(selectedDate).getUTCDay();
|
||||
const dayHorarios = allHorarios?.filter(h => h.dia_semana === dayOfWeek && h.ativo) || [];
|
||||
|
||||
if (dayHorarios.length === 0) {
|
||||
// Default 8-18 fallback if no hours set
|
||||
for (let time = 8 * 60; time < 18 * 60; time += 15) {
|
||||
const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end);
|
||||
if (!isOccupied) {
|
||||
const hour = Math.floor(time / 60), minute = time % 60;
|
||||
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const h of dayHorarios) {
|
||||
const [hStart, mStart] = h.hora_inicio.split(':').map(Number);
|
||||
const [hEnd, mEnd] = h.hora_fim.split(':').map(Number);
|
||||
for (let time = hStart * 60 + mStart; time < hEnd * 60 + mEnd; time += 15) {
|
||||
const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end);
|
||||
if (!isOccupied) {
|
||||
const hour = Math.floor(time / 60), minute = time % 60;
|
||||
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current time is not on the 15m grid (like 12:03), add it as a valid slot
|
||||
if (selectedTime && !slots.includes(selectedTime)) {
|
||||
slots.push(selectedTime);
|
||||
slots.sort();
|
||||
}
|
||||
|
||||
return slots;
|
||||
}, [selectedDentistId, selectedDate, agendamentos, initialAppointment, selectedTime, allHorarios])();
|
||||
|
||||
const handlePatientSelect = (patient: Paciente) => {
|
||||
setSelectedPatient(patient);
|
||||
setSearchTerm('');
|
||||
setIsSearchFocused(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedPatient || !selectedDate || !selectedTime || !selectedDentistId || !procedimento) {
|
||||
toast.error("PREENCHA TODOS OS CAMPOS PARA AGENDAR.");
|
||||
return;
|
||||
}
|
||||
const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + duration * 60000);
|
||||
try {
|
||||
const agData = {
|
||||
pacienteNome: selectedPatient.nome,
|
||||
dentistaId: selectedDentistId,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
status: initialAppointment?.status || 'Pendente',
|
||||
procedimento: procedimento.toUpperCase(),
|
||||
};
|
||||
|
||||
if (initialAppointment?.id) {
|
||||
await HybridBackend.atualizarAgendamento(initialAppointment.id, agData);
|
||||
toast.success(`AGENDAMENTO DE ${selectedPatient.nome} ATUALIZADO!`);
|
||||
} else {
|
||||
await HybridBackend.salvarAgendamento(agData);
|
||||
toast.success(`AGENDAMENTO PARA ${selectedPatient.nome} SALVO!`);
|
||||
}
|
||||
refreshAgendamentos();
|
||||
onSave();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR AGENDAMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-orange-50 to-white">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2 uppercase">
|
||||
<CalendarIcon size={22} className="text-orange-500" /> AGENDAR CONSULTA
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<div className="mb-6">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE</label>
|
||||
{!selectedPatient ? (
|
||||
<div className="relative mt-2">
|
||||
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value.toUpperCase())} onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} placeholder="DIGITE O NOME OU CPF..." className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base" />
|
||||
{isSearchFocused && debouncedSearchTerm && (
|
||||
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
||||
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
||||
{searchResults?.map(p => (
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">{p.nome} ({p.cpf})</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedPatient && (
|
||||
<div className="animate-in fade-in space-y-6 lg:space-y-0">
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-x-8 lg:gap-y-6 space-y-5 lg:space-y-0">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DENTISTA RESPONSÁVEL</label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<select value={selectedDentistId} onChange={e => setSelectedDentistId(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
||||
{dentists?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
<div className="w-7 h-7 rounded-full flex-shrink-0 border-2 border-white shadow-md" style={{ backgroundColor: selectedDentist?.corAgenda || '#ccc' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA</label>
|
||||
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)} className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DURAÇÃO</label>
|
||||
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
||||
<option value={15}>15 MIN</option>
|
||||
<option value={30}>30 MIN</option>
|
||||
<option value={45}>45 MIN</option>
|
||||
<option value={60}>60 MIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PROCEDIMENTO</label>
|
||||
<input type="text" value={procedimento} onChange={e => setProcedimento(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO, LIMPEZA..." className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 uppercase-input placeholder-gray-400 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">HORÁRIOS DISPONÍVEIS</label>
|
||||
<div className="grid grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2 mt-3">
|
||||
{timeSlots.map(slot => (
|
||||
<button type="button" key={slot} onClick={() => setSelectedTime(slot)} className={`py-2.5 lg:py-3 rounded-lg text-sm font-bold border transition-all duration-150 ${selectedTime === slot ? 'bg-blue-600 text-white border-blue-600 shadow-md scale-105' : 'bg-white text-gray-800 border-gray-300 hover:border-blue-400 hover:bg-blue-50'}`}>{slot}</button>
|
||||
))}
|
||||
{timeSlots.length === 0 && <p className="col-span-6 lg:col-span-8 xl:col-span-10 text-center text-sm text-gray-400 p-8 bg-gray-50 rounded-lg border border-dashed border-gray-200 uppercase font-bold">NENHUM HORÁRIO DISPONÍVEL.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
<button type="button" onClick={onClose} className="px-8 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" disabled={!selectedPatient} className="px-10 py-2.5 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg shadow-sm transition-colors text-sm uppercase disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"><CalendarIcon size={16} /> CONFIRMAR</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgendaView: React.FC = () => {
|
||||
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (agendamentos && dentists) {
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
const isDentist = currentRole === 'dentista';
|
||||
const loginedDentistId = dentists.find(d => d.email === currentUserEmail)?.id;
|
||||
|
||||
let filteredAgendamentos = agendamentos;
|
||||
if (isDentist) {
|
||||
filteredAgendamentos = agendamentos.filter(ag => ag.dentistaId === loginedDentistId);
|
||||
}
|
||||
|
||||
const mappedEvents = filteredAgendamentos.map(ag => {
|
||||
const dentist = dentists.find(d => d.id === ag.dentistaId);
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${ag.pacienteNome} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
backgroundColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
borderColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
};
|
||||
});
|
||||
|
||||
// Add Google events if any
|
||||
if (googleEvents && googleEvents.length > 0) {
|
||||
let filteredGoogleEvents = googleEvents;
|
||||
if (isDentist) {
|
||||
filteredGoogleEvents = googleEvents.filter(ev => ev.extendedProps.owner_id === currentUserEmail);
|
||||
}
|
||||
mappedEvents.push(...filteredGoogleEvents);
|
||||
}
|
||||
|
||||
setEvents(mappedEvents);
|
||||
}
|
||||
}, [agendamentos, dentists, googleEvents]);
|
||||
|
||||
const handleDateClick = (arg: any) => { setSelectedDateInfo(arg); setIsModalOpen(true); };
|
||||
const handleEventClick = (info: any) => {
|
||||
if (info.event.extendedProps.isGoogle) {
|
||||
toast.info(`EVENTO DO GOOGLE CALENDAR: ${info.event.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ag = info.event.extendedProps as Agendamento;
|
||||
setSelectedAppointment(ag);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: Agendamento['status']) => {
|
||||
try {
|
||||
await HybridBackend.atualizarAgendamento(id, { status });
|
||||
toast.success(`STATUS ATUALIZADO PARA ${status.toUpperCase()}`);
|
||||
refresh();
|
||||
setShowDetails(false);
|
||||
} catch {
|
||||
toast.error("ERRO AO ATUALIZAR STATUS.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAppointment = async (id: string) => {
|
||||
if (!confirm("DESEJA REALMENTE EXCLUIR ESTE AGENDAMENTO?")) return;
|
||||
try {
|
||||
await HybridBackend.excluirAgendamento(id);
|
||||
toast.success("AGENDAMENTO EXCLUÍDO!");
|
||||
refresh();
|
||||
setShowDetails(false);
|
||||
} catch {
|
||||
toast.error("ERRO AO EXCLUIR AGENDAMENTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointment = () => { refresh(); refreshGoogle(); setIsModalOpen(false); setSelectedAppointment(null); };
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !dentists) return;
|
||||
|
||||
const items = Array.from(dentists);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
const updatedOrders = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
ordem: index
|
||||
}));
|
||||
|
||||
try {
|
||||
await HybridBackend.reorderDentistas(updatedOrders);
|
||||
toast.success("ORDEM DOS DENTISTAS ATUALIZADA!");
|
||||
refreshDentists();
|
||||
} catch {
|
||||
toast.error("ERRO AO REORDENAR DENTISTAS.");
|
||||
}
|
||||
};
|
||||
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
|
||||
const filteredDentists = dentists?.filter(d => {
|
||||
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
||||
if (currentRole === 'dentista') return d.email === currentUserEmail;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="AGENDA CLÍNICA">
|
||||
<div className="flex gap-4 items-center">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="dentists-legend" direction="horizontal">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
|
||||
{filteredDentists?.map((d, index) => (
|
||||
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`flex items-center gap-1.5 text-[10px] text-gray-700 bg-white border border-gray-200 px-3 py-1.5 rounded-full uppercase font-bold shadow-sm transition-all ${currentRole !== 'dentista' ? 'cursor-grab active:cursor-grabbing hover:border-gray-300' : ''} ${snapshot.isDragging ? 'ring-2 ring-blue-500 scale-105 shadow-lg' : ''}`}
|
||||
>
|
||||
{currentRole !== 'dentista' && <GripVertical size={12} className="text-gray-400" />}
|
||||
<div className="w-2.5 h-2.5 rounded-full shadow-inner" style={{ backgroundColor: d.corAgenda }}></div>
|
||||
{d.nome}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
|
||||
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
|
||||
<GearIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-blue-700 shadow-sm transition-colors">
|
||||
<Plus size={16} /> NOVO AGENDAMENTO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 relative" style={{ height: '750px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
||||
locale="pt-br"
|
||||
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
|
||||
slotMinTime="08:00:00"
|
||||
slotMaxTime="19:00:00"
|
||||
allDaySlot={false}
|
||||
events={events}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||
eventContent={(eventInfo) => {
|
||||
const isGoogle = eventInfo.event.extendedProps.isGoogle;
|
||||
return (
|
||||
<div className="p-0.5 overflow-hidden uppercase h-full">
|
||||
<div className="text-[10px] font-bold">{eventInfo.timeText}</div>
|
||||
{isGoogle ? (
|
||||
<div className="text-[10px] font-black leading-tight break-words">{eventInfo.event.title}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs truncate font-bold">{eventInfo.event.extendedProps.pacienteNome}</div>
|
||||
<div className="text-[10px] truncate opacity-90">{eventInfo.event.extendedProps.procedimento}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Details "Popover" (Modal style for real estate) */}
|
||||
{showDetails && selectedAppointment && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100">
|
||||
{/* Header with quick actions */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gray-50/30">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-blue-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EDITAR"
|
||||
>
|
||||
<Edit size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAppointment(selectedAppointment.id)}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-red-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EXCLUIR"
|
||||
>
|
||||
<Trash2 size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setShowDetails(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-10 space-y-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-4 bg-blue-50 rounded-2xl">
|
||||
<User size={28} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-black text-gray-900 leading-none uppercase tracking-tighter">{selectedAppointment.pacienteNome}</h3>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-2 bg-gray-100 px-3 py-1 rounded-full w-fit">
|
||||
<Stethoscope size={12} className="text-blue-400" /> {selectedAppointment.procedimento}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 py-6 border-y border-gray-50">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Data e Hora</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<CalendarRange size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<Clock size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentists?.find(d => d.id === selectedAppointment.dentistaId)?.corAgenda }}></div>
|
||||
{(selectedAppointment as any).dentistaNome}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Action Buttons */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Status do Atendimento</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Faltou')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Faltou' ? 'bg-red-50 text-red-600 border-red-200' : 'bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-500'}`}
|
||||
>
|
||||
<AlertCircle size={16} /> Marcar Falta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Confirmado')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Confirmado' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : 'bg-white border-gray-100 text-gray-500 hover:border-emerald-200 hover:text-emerald-500'}`}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Reagendar */}
|
||||
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex gap-4">
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="flex-1 py-4 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
||||
>
|
||||
Reagendar Consulta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.animate-in { animation: animate-in 0.2s ease-out; }
|
||||
@keyframes animate-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
`}</style>
|
||||
<AppointmentModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setSelectedAppointment(null); }}
|
||||
onSave={handleSaveAppointment}
|
||||
dentists={dentists}
|
||||
initialDate={selectedDateInfo?.dateStr}
|
||||
initialAppointment={selectedAppointment}
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Cores profissionais permitidas
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Azul Score', value: '#2563eb' },
|
||||
{ name: 'Verde Esmeralda', value: '#059669' },
|
||||
{ name: 'Indigo Premium', value: '#4f46e5' },
|
||||
{ name: 'Vinho Elegante', value: '#9f1239' },
|
||||
{ name: 'Slate Moderno', value: '#334155' },
|
||||
{ name: 'Laranja Vibrante', value: '#ea580c' },
|
||||
{ name: 'Teal Clínico', value: '#0d9488' },
|
||||
{ name: 'Roxo Nobre', value: '#7c3aed' },
|
||||
];
|
||||
|
||||
export const ClinicasView: React.FC = () => {
|
||||
const workspaces = HybridBackend.getWorkspaces();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const toast = useToast();
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
|
||||
|
||||
const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
|
||||
const [savingColor, setSavingColor] = useState(false);
|
||||
|
||||
const handleSwitch = (workspaceId: string) => {
|
||||
if (activeWorkspace?.id === workspaceId) {
|
||||
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
|
||||
return;
|
||||
}
|
||||
HybridBackend.switchWorkspace(workspaceId);
|
||||
toast.success("TROCANDO DE UNIDADE...");
|
||||
};
|
||||
|
||||
const handleUpdateColor = async (workspaceId: string, color: string) => {
|
||||
setSavingColor(true);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3005/api/clinicas/${workspaceId}/color`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cor: color })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success("COR DA UNIDADE ATUALIZADA!");
|
||||
|
||||
// Atualizar o localStorage local para refletir a mudança sem refresh imediato no objeto workspaces
|
||||
const updatedWorkspaces = workspaces.map(w => w.id === workspaceId ? { ...w, cor: color } : w);
|
||||
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(updatedWorkspaces));
|
||||
|
||||
// Se a clínica editada for a ativa, atualizamos ela também
|
||||
if (activeWorkspace?.id === workspaceId) {
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify({ ...activeWorkspace, cor: color }));
|
||||
}
|
||||
|
||||
setIsEditingColor(null);
|
||||
// Forçamos um reload após um pequeno delay para aplicar as cores globalmente via CSS/Variables se necessário
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error("ERRO AO SALVAR COR.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("ERRO DE CONEXÃO.");
|
||||
} finally {
|
||||
setSavingColor(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-500">
|
||||
<PageHeader
|
||||
title="GESTÃO DE UNIDADES"
|
||||
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{workspaces.map((ws) => {
|
||||
const isActive = activeWorkspace?.id === ws.id;
|
||||
const clinColor = ws.cor || '#2563eb';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`group relative bg-white rounded-[2.5rem] p-1 border-2 transition-all duration-500 ${isActive
|
||||
? 'shadow-[0_20px_50px_-15px_rgba(0,0,0,0.1)]'
|
||||
: 'border-transparent hover:border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)]'
|
||||
}`}
|
||||
style={{ borderColor: isActive ? clinColor : 'transparent' }}
|
||||
>
|
||||
<div className="px-8 py-10 rounded-[2.2rem] bg-white h-full flex flex-col">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div
|
||||
className={`w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 group-hover:scale-110 shadow-lg`}
|
||||
style={{
|
||||
backgroundColor: clinColor,
|
||||
color: '#fff',
|
||||
boxShadow: `0 10px 20px -5px ${clinColor}44`
|
||||
}}
|
||||
>
|
||||
<Building2 size={32} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{isActive && (
|
||||
<span
|
||||
className="text-white text-[9px] font-black px-4 py-1.5 rounded-full uppercase tracking-widest shadow-sm"
|
||||
style={{ backgroundColor: clinColor }}
|
||||
>
|
||||
ATIVA
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsEditingColor(isEditingColor === ws.id ? null : ws.id)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 bg-gray-50 rounded-xl transition-colors"
|
||||
title="Mudar Cor"
|
||||
>
|
||||
<Palette size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-grow">
|
||||
<h3 className="text-2xl font-black text-gray-900 uppercase tracking-tighter leading-tight">
|
||||
{ws.nome}
|
||||
</h3>
|
||||
|
||||
{isEditingColor === ws.id ? (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-2xl border border-gray-100 animate-in slide-in-from-top-2 duration-300">
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-3">Escolha a cor da marca</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => handleUpdateColor(ws.id, c.value)}
|
||||
disabled={savingColor}
|
||||
className={`w-full h-8 rounded-lg border-2 transition-all ${ws.cor === c.value ? 'border-white ring-2 ring-gray-300' : 'border-transparent hover:scale-110'}`}
|
||||
style={{ backgroundColor: c.value }}
|
||||
title={c.name}
|
||||
>
|
||||
{savingColor && ws.cor === c.value && <Loader2 size={12} className="animate-spin text-white mx-auto" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<MapPin size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Unidade Operacional</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-50 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: `${clinColor}11`, color: clinColor }}>
|
||||
<Shield size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest leading-none mb-1">Seu Perfil</p>
|
||||
<p className="text-xs font-bold text-gray-700 uppercase">{ws.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSwitch(ws.id)}
|
||||
disabled={isActive || isEditingColor === ws.id}
|
||||
className={`mt-10 w-full py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all duration-300 flex items-center justify-center gap-3 group/btn shadow-lg`}
|
||||
style={{
|
||||
backgroundColor: isActive ? '#f3f4f6' : clinColor,
|
||||
color: isActive ? '#9ca3af' : '#fff',
|
||||
boxShadow: isActive ? 'none' : `0 10px 20px -10px ${clinColor}66`
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<>UNIDADE ATUAL <BadgeCheck size={18} /></>
|
||||
) : (
|
||||
<>ACESSAR UNIDADE <ArrowRight size={18} className="group-hover/btn:translate-x-1 transition-transform" /></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] p-10 border border-gray-100 flex flex-col md:flex-row items-center justify-between gap-6 shadow-sm">
|
||||
<div className="text-center md:text-left">
|
||||
<h4 className="text-lg font-black text-gray-900 uppercase tracking-tight italic">Identidade Visual Dinâmica</h4>
|
||||
<p className="text-sm text-gray-500 font-medium">A cor escolhida será aplicada automaticamente em toda a interface desta clínica.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{PRESET_COLORS.slice(0, 4).map(c => (
|
||||
<div key={c.value} className="w-3 h-3 rounded-full" style={{ backgroundColor: c.value }}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Users,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Megaphone,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Plus,
|
||||
Clock,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { NotificationCenter } from '../components/NotificationCenter.tsx';
|
||||
|
||||
const StatCard: React.FC<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: string;
|
||||
isPositive?: boolean;
|
||||
color: 'blue' | 'green' | 'purple' | 'amber';
|
||||
onClick?: () => void;
|
||||
}> = ({ title, value, icon, trend, isPositive, color, onClick }) => {
|
||||
const bgLight = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
purple: 'bg-indigo-50 text-indigo-600',
|
||||
amber: 'bg-amber-50 text-amber-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-[2rem] p-8 border border-gray-100 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)] hover:shadow-[0_20px_50px_-20px_rgba(0,0,0,0.1)] transition-all duration-500 group relative overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 opacity-[0.03] -mr-8 -mt-8 rounded-full bg-current transform group-hover:scale-110 transition-transform duration-700"></div>
|
||||
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
<div className="space-y-4">
|
||||
<div className={`w-12 h-12 ${bgLight[color]} rounded-2xl flex items-center justify-center mb-2 group-hover:scale-110 transition-transform`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">{title}</p>
|
||||
<h3 className="text-3xl font-black text-gray-900 tracking-tight mt-1">{value}</h3>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1.5 text-xs font-bold ${isPositive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
<div className={`p-0.5 rounded-full ${isPositive ? 'bg-emerald-100' : 'bg-rose-100'}`}>
|
||||
{isPositive ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
</div>
|
||||
{trend}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const { data, isLoading } = useHybridBackend<any>(HybridBackend.getDashboardStats);
|
||||
|
||||
const growthData = useMemo(() => {
|
||||
if (!data?.growth || data.growth.length === 0) {
|
||||
return Array(6).fill(null).map((_, i) => ({
|
||||
label: `MÊS ${i + 1}`,
|
||||
receita: 0,
|
||||
despesa: 0,
|
||||
heightP: '0%',
|
||||
heightR: '0%'
|
||||
}));
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...data.growth.map((g: any) => Math.max(parseFloat(g.receita), parseFloat(g.despesa))), 1000);
|
||||
|
||||
return data.growth.map((g: any) => {
|
||||
const [year, month] = g.mes.split('-');
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||
const label = date.toLocaleString('pt-BR', { month: 'short' }).toUpperCase();
|
||||
|
||||
return {
|
||||
label,
|
||||
receita: parseFloat(g.receita),
|
||||
despesa: parseFloat(g.despesa),
|
||||
// Proporção para o gráfico
|
||||
heightR: `${(parseFloat(g.receita) / maxVal) * 100}%`,
|
||||
heightD: `${(parseFloat(g.despesa) / maxVal) * 100}%`
|
||||
};
|
||||
}).slice(-6); // Garantir últimos 6
|
||||
}, [data]);
|
||||
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const showFinancials = ['admin', 'donoclinica', 'funcionario'].includes(currentRole);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = data?.stats || {
|
||||
totalPacientes: 0,
|
||||
totalAgendamentos: 0,
|
||||
totalLeads: 0,
|
||||
faturamentoTotal: 0,
|
||||
despesasTotal: 0
|
||||
};
|
||||
|
||||
const recent = data?.recentAgendamentos || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<PageHeader
|
||||
title="DASHBOARD ANALÍTICO"
|
||||
description="Informações consolidadas em tempo real da sua clínica."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="bg-white text-gray-900 border border-gray-200 px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-gray-50 transition-all flex items-center gap-2 shadow-sm">
|
||||
<Clock size={16} /> Relatórios
|
||||
</button>
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') && (
|
||||
<button
|
||||
onClick={() => window.location.hash = '#/pacientes'}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all flex items-center gap-2 shadow-lg shadow-blue-100"
|
||||
>
|
||||
<Plus size={16} /> Novo Paciente
|
||||
</button>
|
||||
)}
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-${showFinancials ? '4' : '2'} gap-8`}>
|
||||
<StatCard
|
||||
title="Pacientes Ativos"
|
||||
value={stats.totalPacientes}
|
||||
icon={<Users size={24} />}
|
||||
trend="+12% este mês"
|
||||
isPositive={true}
|
||||
color="blue"
|
||||
onClick={() => window.location.hash = '#/pacientes'}
|
||||
/>
|
||||
{showFinancials && (
|
||||
<StatCard
|
||||
title="Faturamento Bruto"
|
||||
value={new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(stats.faturamentoTotal)}
|
||||
icon={<DollarSign size={24} />}
|
||||
trend="+5.4% vs mês ant."
|
||||
isPositive={true}
|
||||
color="green"
|
||||
onClick={() => window.location.hash = '#/financeiro'}
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
title="Agendamentos"
|
||||
value={stats.totalAgendamentos}
|
||||
icon={<Calendar size={24} />}
|
||||
trend="82% de ocupação"
|
||||
isPositive={true}
|
||||
color="purple"
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
/>
|
||||
{showFinancials && (
|
||||
<StatCard
|
||||
title="Novas Leads"
|
||||
value={stats.totalLeads}
|
||||
icon={<Megaphone size={24} />}
|
||||
trend="-2% vs ontem"
|
||||
isPositive={false}
|
||||
color="amber"
|
||||
onClick={() => window.location.hash = '#/leads'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`grid lg:grid-cols-${showFinancials ? '3' : '1'} gap-8`}>
|
||||
{/* Growth Chart Area */}
|
||||
{showFinancials && (
|
||||
<div className="lg:col-span-2 bg-white rounded-[2.5rem] p-10 border border-gray-100 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.03)]">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h4 className="text-xl font-black text-gray-900 uppercase tracking-tighter italic">Crescimento Mensal</h4>
|
||||
<p className="text-xs font-bold text-gray-400 mt-1 uppercase">Receitas vs Despesas Reais</p>
|
||||
</div>
|
||||
<select
|
||||
id="chart-period-dashboard"
|
||||
name="chart-period"
|
||||
className="bg-gray-50 border-none text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl focus:ring-2 focus:ring-blue-100 cursor-pointer"
|
||||
>
|
||||
<option value="6m">Últimos 6 meses</option>
|
||||
<option value="year">Este Ano</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-64 flex items-end justify-between gap-4 px-4">
|
||||
{growthData.map((d: any, i: number) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-3 group">
|
||||
<div className="w-full flex justify-center gap-1 items-end h-[200px] bg-slate-50/50 rounded-2xl p-1">
|
||||
{/* Barra Despesa */}
|
||||
<div
|
||||
title={`Despesa: R$ ${d.despesa}`}
|
||||
className="w-1/3 bg-rose-200 rounded-lg group-hover:bg-rose-300 transition-all duration-500 origin-bottom"
|
||||
style={{ height: d.heightD || '5%' }}
|
||||
></div>
|
||||
{/* Barra Receita */}
|
||||
<div
|
||||
title={`Receita: R$ ${d.receita}`}
|
||||
className="w-1/3 bg-blue-500 rounded-lg group-hover:bg-blue-600 shadow-sm group-hover:shadow-blue-200 transition-all duration-500 origin-bottom"
|
||||
style={{ height: d.heightR || '5%' }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{d.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-sm"></div>
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase">Receitas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-rose-200 rounded-sm"></div>
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase">Despesas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-gray-900 rounded-[2.5rem] p-10 shadow-2xl shadow-blue-200 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/20 blur-[80px] rounded-full"></div>
|
||||
<div className="relative z-10 h-full flex flex-col">
|
||||
<h4 className="text-xl font-black text-white uppercase tracking-tighter italic mb-8">Últimos Agendamentos</h4>
|
||||
|
||||
<div className="space-y-6 flex-1 overflow-y-auto custom-scrollbar pr-2">
|
||||
{recent.length > 0 ? recent.map((app: any) => (
|
||||
<div
|
||||
key={app.id}
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
className="flex items-center gap-4 group cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center text-blue-400 group-hover:bg-blue-600 group-hover:text-white transition-all duration-300 flex-shrink-0">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-black text-white uppercase tracking-tight truncate">{app.pacienteNome || 'Paciente'}</div>
|
||||
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5 truncate">
|
||||
{app.dentistaNome || 'Dr. Dentista'} • {new Date(app.start_time).toLocaleDateString('pt-BR')} {new Date(app.start_time).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-40">
|
||||
<Calendar size={40} className="text-white mb-2" />
|
||||
<p className="text-white text-[10px] font-bold uppercase tracking-widest">Nenhum agendamento recente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.hash = '#/agenda'}
|
||||
className="w-full mt-8 bg-white/5 border border-white/10 text-white py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-white/10 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
Ver Agenda Completa <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, Lock, User, Stethoscope, Phone, ShieldCheck, Mail, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export const DentistRegisterView: React.FC = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Get data from URL
|
||||
const query = new URLSearchParams(window.location.hash.split('?')[1]);
|
||||
const clinicaId = query.get('clinica');
|
||||
const tempName = query.get('nome');
|
||||
const token = query.get('token');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome: tempName || '',
|
||||
email: '',
|
||||
senha: '',
|
||||
cro: '',
|
||||
cro_uf: 'MS',
|
||||
celular: '',
|
||||
especialidade: ''
|
||||
});
|
||||
|
||||
if (!token || !clinicaId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-20 h-20 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto shadow-sm">
|
||||
<Lock size={40} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-gray-900 uppercase">LINK INVÁLIDO OU EXPIRADO</h2>
|
||||
<p className="text-gray-500 max-w-sm mx-auto font-medium">Por favor, solicite um novo convite ao administrador da clínica.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/dentistas/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
id: 'd_' + Math.random().toString(36).substring(2, 9),
|
||||
clinicaId
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success("CADASTRO REALIZADO COM SUCESSO!");
|
||||
setStep(3);
|
||||
} else {
|
||||
toast.error("ERRO AO REALIZAR CADASTRO.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("FALHA DE CONEXÃO.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center p-4">
|
||||
<div className="max-w-xl w-full">
|
||||
{/* Progress Header */}
|
||||
<div className="mb-10 text-center space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase tracking-widest border border-blue-100 mb-4">
|
||||
<ShieldCheck size={14} /> Convite de Profissional
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-gray-900 tracking-tighter uppercase leading-none">Complete seu Perfil</h1>
|
||||
<p className="text-gray-500 font-medium">Bem-vindo ao SCOREODONTO. Você está sendo convidado para a unidade: <span className="text-blue-600 font-bold uppercase">{clinicaId}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] shadow-[0_50px_100px_-20px_rgba(0,0,0,0.08)] border border-gray-100 overflow-hidden relative">
|
||||
<div className="h-2 bg-gray-100 w-full">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-700"
|
||||
style={{ width: `${(step / 3) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="p-10 animate-in fade-in slide-in-from-right-10 duration-500">
|
||||
<form className="space-y-6" onSubmit={(e) => { e.preventDefault(); setStep(2); }}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Seu Nome Profissional</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-blue-500 transition-colors" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.nome}
|
||||
onChange={e => setFormData({ ...formData, nome: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="NOME COMPLETO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Email de Acesso</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Senha de Acesso</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.senha}
|
||||
onChange={e => setFormData({ ...formData, senha: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gray-900 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-600 hover:shadow-2xl hover:shadow-blue-200 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
Próximo Passo <ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="p-10 animate-in fade-in slide-in-from-right-10 duration-500">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Inscrição CRO</label>
|
||||
<div className="relative group">
|
||||
<Database className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.cro}
|
||||
onChange={e => setFormData({ ...formData, cro: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="00000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Estado CRO</label>
|
||||
<select
|
||||
value={formData.cro_uf}
|
||||
onChange={e => setFormData({ ...formData, cro_uf: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-6 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800"
|
||||
>
|
||||
<option>MS</option><option>SP</option><option>RJ</option><option>MG</option><option>PR</option><option>SC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">Especialidade Principal</label>
|
||||
<div className="relative group">
|
||||
<Stethoscope className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.especialidade}
|
||||
onChange={e => setFormData({ ...formData, especialidade: e.target.value.toUpperCase() })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="EX: ORTODONTIA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1 mb-2 block">WhatsApp de Contato</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
required
|
||||
value={formData.celular}
|
||||
onChange={e => setFormData({ ...formData, celular: e.target.value })}
|
||||
className="w-full bg-gray-50 border border-transparent focus:border-blue-500 focus:bg-white px-12 py-4 rounded-2xl outline-none transition-all font-bold text-gray-800 uppercase"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 bg-gray-100 text-gray-600 py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-gray-200 transition-all font-bold"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-[2] bg-blue-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-700 shadow-xl shadow-blue-200 transition-all flex items-center justify-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <>Finalizar e Entrar <ArrowRight size={20} /></>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="p-16 text-center animate-in zoom-in duration-500">
|
||||
<div className="w-24 h-24 bg-green-50 text-green-500 rounded-[2rem] flex items-center justify-center mx-auto mb-8 shadow-inner ring-1 ring-green-100">
|
||||
<ShieldCheck size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-gray-900 uppercase tracking-tight mb-4">Tudo Pronto!</h2>
|
||||
<p className="text-gray-500 font-medium mb-10">Seu perfil foi criado e você já está vinculado à unidade <br /> <span className="text-blue-600 font-bold">{clinicaId}</span>.</p>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.hash = '/login'}
|
||||
className="w-full bg-gray-900 text-white py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-blue-600 shadow-2xl transition-all"
|
||||
>
|
||||
Ir para o Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-gray-400 text-[10px] font-black uppercase tracking-widest">
|
||||
<ShieldCheck size={14} /> Ambiente Seguro e Auditorado por Administradores
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Filter, Plus, TrendingUp, Clock, TrendingDown, CheckCircle, Download, Trash2, X, DollarSign, Loader2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { FinanceiroItem, Paciente } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// --- Reusable Debounce Hook ---
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// ----------- NOVO LANÇAMENTO MODAL -----------
|
||||
const NovoLancamentoModal: React.FC<{
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<Paciente[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchTerm) { setSearchResults([]); return; }
|
||||
setIsSearching(true);
|
||||
HybridBackend.getPacientes(debouncedSearchTerm).then(results => {
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
});
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
const handlePatientSelect = (patient: Paciente) => {
|
||||
setSelectedPatient(patient);
|
||||
setSearchTerm('');
|
||||
setIsSearchFocused(false);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState({
|
||||
id: crypto.randomUUID(),
|
||||
descricao: '',
|
||||
valor: '',
|
||||
dataVencimento: new Date().toISOString().split('T')[0],
|
||||
status: 'Pendente' as 'Pago' | 'Pendente' | 'Atrasado',
|
||||
formaPagamento: 'Pix' as 'Boleto' | 'Pix' | 'Cartão' | 'Dinheiro',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedPatient || !form.descricao || !form.valor) {
|
||||
toast.error('PREENCHA TODOS OS CAMPOS OBRIGATÓRIOS.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.saveFinanceiro({
|
||||
...form,
|
||||
pacienteNome: selectedPatient.nome,
|
||||
valor: parseFloat(form.valor),
|
||||
});
|
||||
toast.success('LANÇAMENTO SALVO COM SUCESSO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO SALVAR LANÇAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-3xl lg:w-[70vw] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
{/* HEADER */}
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-green-50 to-white">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<DollarSign size={22} className="text-green-600" /> NOVO LANÇAMENTO
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{/* BODY */}
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
{/* PACIENTE SEARCH */}
|
||||
<div className="mb-6">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE *</label>
|
||||
{!selectedPatient ? (
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value.toUpperCase())}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)}
|
||||
placeholder="DIGITE O NOME OU CPF..."
|
||||
className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base"
|
||||
/>
|
||||
{isSearchFocused && debouncedSearchTerm && (
|
||||
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
||||
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
||||
{searchResults.map(p => (
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">
|
||||
{p.nome} ({p.cpf})
|
||||
</li>
|
||||
))}
|
||||
{!isSearching && searchResults.length === 0 && <li className="p-3 text-sm text-gray-500">NENHUM PACIENTE ENCONTRADO.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:grid lg:grid-cols-2 lg:gap-x-8 lg:gap-y-5 space-y-5 lg:space-y-0">
|
||||
{/* DESCRIÇÃO */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DESCRIÇÃO *</label>
|
||||
<input type="text" required value={form.descricao}
|
||||
onChange={e => setForm(f => ({ ...f, descricao: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 uppercase-input text-sm" placeholder="EX: CONSULTA AVALIAÇÃO, LIMPEZA..." />
|
||||
</div>
|
||||
{/* VALOR */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">VALOR (R$) *</label>
|
||||
<input type="number" step="0.01" required value={form.valor}
|
||||
onChange={e => setForm(f => ({ ...f, valor: e.target.value }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" placeholder="0,00" />
|
||||
</div>
|
||||
{/* VENCIMENTO */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA VENCIMENTO</label>
|
||||
<input type="date" value={form.dataVencimento}
|
||||
onChange={e => setForm(f => ({ ...f, dataVencimento: e.target.value }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 text-sm" />
|
||||
</div>
|
||||
{/* STATUS */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">STATUS</label>
|
||||
<select value={form.status}
|
||||
onChange={e => setForm(f => ({ ...f, status: e.target.value as any }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
||||
<option value="Pendente">PENDENTE</option>
|
||||
<option value="Pago">PAGO</option>
|
||||
<option value="Atrasado">ATRASADO</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* FORMA DE PAGAMENTO */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">FORMA DE PAGAMENTO</label>
|
||||
<select value={form.formaPagamento}
|
||||
onChange={e => setForm(f => ({ ...f, formaPagamento: e.target.value as any }))}
|
||||
className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white text-sm uppercase-select">
|
||||
<option value="Pix">PIX</option>
|
||||
<option value="Boleto">BOLETO</option>
|
||||
<option value="Cartão">CARTÃO</option>
|
||||
<option value="Dinheiro">DINHEIRO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold rounded-lg text-sm uppercase transition-colors">CANCELAR</button>
|
||||
<button type="submit" className="px-8 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm text-sm uppercase flex items-center gap-2 transition-colors">
|
||||
<Plus size={16} /> SALVAR
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------- MAIN VIEW -----------
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const FinanceiroView: React.FC = () => {
|
||||
const [transacoes, setTransacoes] = useState<FinanceiroItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtroStatus, setFiltroStatus] = useState<'TODOS' | 'Pago' | 'Pendente' | 'Atrasado'>('TODOS');
|
||||
const [showFiltro, setShowFiltro] = useState(false);
|
||||
const [isNovoOpen, setIsNovoOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
HybridBackend.getFinanceiro().then((data) => {
|
||||
setTransacoes(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const transacoesFiltradas = filtroStatus === 'TODOS' ? transacoes : transacoes.filter(t => t.status === filtroStatus);
|
||||
|
||||
const totalRecebido = transacoes.filter(t => t.status === 'Pago').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
const totalPendente = transacoes.filter(t => t.status === 'Pendente').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
const totalAtrasado = transacoes.filter(t => t.status === 'Atrasado').reduce((acc, t) => acc + Number(t.valor), 0);
|
||||
|
||||
const handleConfirmarPagamento = async (item: FinanceiroItem) => {
|
||||
if (!window.confirm(`CONFIRMAR PAGAMENTO DE R$ ${Number(item.valor).toFixed(2)} DE ${item.pacienteNome}?`)) return;
|
||||
try {
|
||||
await HybridBackend.updateFinanceiro({ ...item, status: 'Pago' });
|
||||
toast.success(`PAGAMENTO DE ${item.pacienteNome} CONFIRMADO!`);
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('ERRO AO CONFIRMAR PAGAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluir = async (item: FinanceiroItem) => {
|
||||
if (!window.confirm(`TEM CERTEZA QUE DESEJA EXCLUIR O LANÇAMENTO DE ${item.pacienteNome}? ESTA AÇÃO NÃO PODE SER DESFEITA.`)) return;
|
||||
try {
|
||||
await HybridBackend.deleteFinanceiro(item.id);
|
||||
toast.success('LANÇAMENTO EXCLUÍDO!');
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('ERRO AO EXCLUIR LANÇAMENTO.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (item: FinanceiroItem) => {
|
||||
toast.success(`RECIBO DE ${item.pacienteNome} GERADO COM SUCESSO!`);
|
||||
};
|
||||
|
||||
const filtroLabel = filtroStatus === 'TODOS' ? 'FILTRAR' : filtroStatus.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="FINANCEIRO & FATURAMENTO">
|
||||
<div className="flex gap-2 relative">
|
||||
{/* FILTRAR */}
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowFiltro(!showFiltro)}
|
||||
className={`border px-3 py-2 rounded-lg flex items-center gap-2 font-bold text-xs uppercase transition-colors ${filtroStatus !== 'TODOS' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}>
|
||||
<Filter size={18} /> {filtroLabel}
|
||||
</button>
|
||||
{showFiltro && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl z-20 min-w-[160px] overflow-hidden">
|
||||
{(['TODOS', 'Pago', 'Pendente', 'Atrasado'] as const).map(status => (
|
||||
<button key={status} onClick={() => { setFiltroStatus(status); setShowFiltro(false); }}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm font-bold uppercase hover:bg-gray-50 transition-colors flex items-center justify-between ${filtroStatus === status ? 'text-blue-700 bg-blue-50' : 'text-gray-700'
|
||||
}`}>
|
||||
{status.toUpperCase()}
|
||||
{filtroStatus === status && <div className="w-2 h-2 rounded-full bg-blue-600"></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* NOVO LANÇAMENTO */}
|
||||
<button onClick={() => setIsNovoOpen(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-green-700 font-bold text-xs uppercase shadow-sm transition-colors">
|
||||
<Plus size={18} /> NOVO LANÇAMENTO
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* DASHBOARD CARDS */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">RECEBIDO (MÊS)</p>
|
||||
<h3 className="text-2xl font-bold text-green-600">R$ {totalRecebido.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-full text-green-600"><TrendingUp size={24} /></div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">A RECEBER / PENDENTE</p>
|
||||
<h3 className="text-2xl font-bold text-yellow-600">R$ {totalPendente.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-yellow-100 p-3 rounded-full text-yellow-600"><Clock size={24} /></div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-bold mb-1 uppercase">EM ATRASO</p>
|
||||
<h3 className="text-2xl font-bold text-red-600">R$ {totalAtrasado.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</h3>
|
||||
</div>
|
||||
<div className="bg-red-100 p-3 rounded-full text-red-600"><TrendingDown size={24} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LISTA DE TRANSAÇÕES */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||
<h3 className="font-bold text-gray-800 uppercase">
|
||||
LANÇAMENTOS {filtroStatus !== 'TODOS' && <span className="text-blue-600 text-sm">({filtroStatus.toUpperCase()})</span>}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 font-bold uppercase">{transacoesFiltradas.length} REGISTRO(S)</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
|
||||
<tr>
|
||||
<th className="px-6 py-3">PACIENTE</th>
|
||||
<th className="px-6 py-3">DESCRIÇÃO</th>
|
||||
<th className="px-6 py-3">VENCIMENTO</th>
|
||||
<th className="px-6 py-3">VALOR</th>
|
||||
<th className="px-6 py-3">STATUS</th>
|
||||
<th className="px-6 py-3 text-right">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="p-6 text-center text-gray-500 uppercase font-bold">
|
||||
<div className="flex items-center justify-center gap-2"><Loader2 className="animate-spin" size={18} /> CARREGANDO...</div>
|
||||
</td></tr>
|
||||
) : transacoesFiltradas.length === 0 ? (
|
||||
<tr><td colSpan={6} className="p-10 text-center text-gray-400 uppercase font-bold text-sm">
|
||||
NENHUM LANÇAMENTO ENCONTRADO {filtroStatus !== 'TODOS' && `COM STATUS "${filtroStatus.toUpperCase()}"`}.
|
||||
</td></tr>
|
||||
) : transacoesFiltradas.map(t => (
|
||||
<tr key={t.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 font-bold text-gray-900 uppercase">{t.pacienteNome}</td>
|
||||
<td className="px-6 py-4 text-gray-600 font-medium uppercase">
|
||||
{t.descricao}
|
||||
<span className="text-[10px] text-gray-400 block font-bold">{t.formaPagamento?.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 font-medium">{new Date(t.dataVencimento).toLocaleDateString('pt-BR')}</td>
|
||||
<td className="px-6 py-4 font-bold text-gray-800">R$ {Number(t.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||
${t.status === 'Pago' ? 'bg-green-100 text-green-700' :
|
||||
t.status === 'Atrasado' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'}`}>
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{t.status !== 'Pago' && (
|
||||
<button onClick={() => handleConfirmarPagamento(t)} title="CONFIRMAR PAGAMENTO"
|
||||
className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg transition-colors">
|
||||
<CheckCircle size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleDownload(t)} title="GERAR RECIBO"
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleExcluir(t)} title="EXCLUIR"
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLOSE FILTRO DROPDOWN when clicking outside */}
|
||||
{showFiltro && <div className="fixed inset-0 z-10" onClick={() => setShowFiltro(false)} />}
|
||||
|
||||
{/* NOVO LANÇAMENTO MODAL */}
|
||||
{isNovoOpen && <NovoLancamentoModal onClose={() => setIsNovoOpen(false)} onSaved={fetchData} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Trash2,
|
||||
Check,
|
||||
Building2,
|
||||
AlertCircle,
|
||||
PlusCircle,
|
||||
Camera,
|
||||
Stethoscope,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// --- STORES ---
|
||||
interface GTOItem {
|
||||
id: string;
|
||||
procedimentoId: string;
|
||||
codigoTUSS: string;
|
||||
codigoInterno: string;
|
||||
descricao: string;
|
||||
dente?: string;
|
||||
face?: string;
|
||||
arco?: string;
|
||||
quantidade: number;
|
||||
valorUnitario: number;
|
||||
valorTotal: number;
|
||||
observacao?: string;
|
||||
}
|
||||
|
||||
interface GTOStore {
|
||||
paciente: Paciente | null;
|
||||
dentista: Dentista | null;
|
||||
credenciada: string;
|
||||
items: GTOItem[];
|
||||
setPaciente: (p: Paciente | null) => void;
|
||||
setDentista: (d: Dentista | null) => void;
|
||||
setCredenciada: (c: string) => void;
|
||||
addItem: (item: GTOItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const useGTOStore = create<GTOStore>((set) => ({
|
||||
paciente: null,
|
||||
dentista: null,
|
||||
credenciada: 'CASSEMS - SEDE CENTRAL',
|
||||
items: [],
|
||||
setPaciente: (paciente) => set({ paciente }),
|
||||
setDentista: (dentista) => set({ dentista }),
|
||||
setCredenciada: (credenciada) => set({ credenciada }),
|
||||
addItem: (item) => set((state) => ({
|
||||
items: [...state.items, item].slice(0, 20)
|
||||
})),
|
||||
removeItem: (id) => set((state) => ({
|
||||
items: state.items.filter(i => i.id !== id)
|
||||
})),
|
||||
clear: () => set({ paciente: null, dentista: null, items: [] }),
|
||||
}));
|
||||
|
||||
// --- ODONTOGRAM DATA (4x2 Grid Layout) ---
|
||||
const DENTES_ADULTO = {
|
||||
q1: [14, 13, 12, 11, 18, 17, 16, 15], // Top: 14-11, Bottom: 18-15
|
||||
q2: [21, 22, 23, 24, 25, 26, 27, 28], // Top: 21-24, Bottom: 25-28
|
||||
q4: [44, 43, 42, 41, 48, 47, 46, 45], // Top: 44-41, Bottom: 48-45
|
||||
q3: [31, 32, 33, 34, 35, 36, 37, 38], // Top: 31-34, Bottom: 35-38
|
||||
extras: { q1: 19, q2: 29, q4: 49, q3: 39 }
|
||||
};
|
||||
|
||||
const DENTES_CRIANCA = {
|
||||
// Balanced 4x2 distribution with nulls for spacing (matching image layout)
|
||||
q1: [null, 53, 52, 51, null, null, 55, 54],
|
||||
q2: [61, 62, 63, null, 64, 65, null, null],
|
||||
q4: [null, 43, 42, 41, null, null, 46, 45],
|
||||
q3: [71, 72, 73, null, 74, 75, null, null]
|
||||
};
|
||||
|
||||
// --- COMPONENT ---
|
||||
export const LancarGTO: React.FC = () => {
|
||||
const store = useGTOStore();
|
||||
const toast = useToast();
|
||||
|
||||
const [pacientes, setPacientes] = useState<Paciente[]>([]);
|
||||
const [dentistas, setDentistas] = useState<Dentista[]>([]);
|
||||
const [especialidades, setEspecialidades] = useState<Especialidade[]>([]);
|
||||
const [procedimentos, setProcedimentos] = useState<Procedimento[]>([]);
|
||||
|
||||
const [selectedEsp, setSelectedEsp] = useState<string>('');
|
||||
const [selectedProc, setSelectedProc] = useState<Procedimento | null>(null);
|
||||
const [pacienteTipo, setPacienteTipo] = useState<'ADULTO' | 'CRIANCA'>('ADULTO');
|
||||
const [selectedDentes, setSelectedDentes] = useState<string[]>([]);
|
||||
const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null);
|
||||
const [face, setFace] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const p = await HybridBackend.getPacientes();
|
||||
const d = await HybridBackend.getDentistas();
|
||||
const es = await HybridBackend.getEspecialidades();
|
||||
const pr = await HybridBackend.getProcedimentos();
|
||||
setPacientes(p);
|
||||
setDentistas(d);
|
||||
setEspecialidades(es);
|
||||
setProcedimentos(pr);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filteredProcedimentos = useMemo(() => {
|
||||
if (!selectedEsp) return [];
|
||||
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
||||
}, [selectedEsp, procedimentos]);
|
||||
|
||||
const toggleDente = (num: string) => {
|
||||
if (selectedProc?.tipo_regiao !== 'DENTE') {
|
||||
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE DENTE INDIVIDUAL.");
|
||||
return;
|
||||
}
|
||||
setSelectedArco(null);
|
||||
setSelectedDentes(prev =>
|
||||
prev.includes(num) ? prev.filter(d => d !== num) : [...prev, num]
|
||||
);
|
||||
};
|
||||
|
||||
const selectArco = (arco: 'AI' | 'AS') => {
|
||||
if (selectedProc?.tipo_regiao !== 'ARCO') {
|
||||
toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE ARCO.");
|
||||
return;
|
||||
}
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(arco);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!selectedProc) {
|
||||
toast.error("SELECIONE UM PROCEDIMENTO PRIMEIRO.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de Região
|
||||
if (selectedProc.tipo_regiao === 'DENTE' && selectedDentes.length === 0) {
|
||||
toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE AO MENOS UM DENTE.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProc.tipo_regiao === 'ARCO' && !selectedArco) {
|
||||
toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE UM ARCO (AI/AS).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de Face
|
||||
if (selectedProc.exige_face && (!face || face.trim() === '')) {
|
||||
toast.error("A FACE É OBRIGATÓRIA PARA ESTE PROCEDIMENTO.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: GTOItem = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
procedimentoId: selectedProc.id,
|
||||
codigoTUSS: selectedProc.codigo || '000000',
|
||||
codigoInterno: selectedProc.codigoInterno || '',
|
||||
descricao: selectedProc.nome,
|
||||
dente: selectedDentes.join(', '),
|
||||
arco: selectedArco || undefined,
|
||||
face: face,
|
||||
quantidade: 1,
|
||||
valorUnitario: selectedProc.valorParticular || 0,
|
||||
valorTotal: selectedProc.valorParticular || 0,
|
||||
observacao: obs,
|
||||
};
|
||||
|
||||
store.addItem(newItem);
|
||||
toast.success("ADICIONADO AO RASCUNHO.");
|
||||
|
||||
// Reset selection fields
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setFace('');
|
||||
setObs('');
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!store.paciente || !store.dentista || store.items.length === 0) {
|
||||
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.success("GTO GERADA COM SUCESSO!");
|
||||
store.clear();
|
||||
setSelectedEsp('');
|
||||
setSelectedProc(null);
|
||||
} catch {
|
||||
toast.error("ERRO AO GERAR GTO.");
|
||||
}
|
||||
};
|
||||
|
||||
const totalGeral = store.items.reduce((sum, i) => sum + i.valorTotal, 0);
|
||||
|
||||
const renderDenteBtn = (num: number | string | null, keyIdx?: number) => {
|
||||
if (num === null) return <div key={`empty-${keyIdx}`} className="w-9 h-9" />;
|
||||
const sStr = num.toString();
|
||||
const isSelected = selectedDentes.includes(sStr);
|
||||
const isDisabled = !!selectedProc && selectedProc.tipo_regiao !== 'DENTE';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sStr}
|
||||
onClick={() => toggleDente(sStr)}
|
||||
disabled={isDisabled}
|
||||
className={`w-9 h-9 rounded-lg border-2 text-[11px] font-black transition-all shadow-sm flex items-center justify-center
|
||||
${isSelected
|
||||
? 'bg-blue-600 border-blue-700 text-white scale-110 z-10'
|
||||
: isDisabled
|
||||
? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50'
|
||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-400 hover:bg-blue-50'}
|
||||
`}
|
||||
>
|
||||
{sStr}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="LANÇAR GTO"
|
||||
description="MONTGEM DINÂMICA DE GUIA ODONTOLÓGICA (GTO)."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6 items-start">
|
||||
|
||||
{/* LEFT PANEL: ODONTOGRAM & ITEMS */}
|
||||
<div className="col-span-12 lg:col-span-8 space-y-6">
|
||||
|
||||
{/* ODONTOGRAM BOX */}
|
||||
<div className="bg-white rounded-3xl border border-gray-100 shadow-xl p-8 space-y-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="inline-flex bg-gray-100 p-1 rounded-xl shadow-inner">
|
||||
<button
|
||||
onClick={() => { setPacienteTipo('ADULTO'); setSelectedDentes([]); }}
|
||||
className={`px-8 py-2 rounded-lg text-xs font-black transition-all uppercase ${pacienteTipo === 'ADULTO' ? 'bg-blue-600 text-white shadow-md scale-105' : 'text-gray-500 hover:text-gray-800'}`}
|
||||
>
|
||||
Adulto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPacienteTipo('CRIANCA'); setSelectedDentes([]); }}
|
||||
className={`px-8 py-2 rounded-lg text-xs font-black transition-all uppercase ${pacienteTipo === 'CRIANCA' ? 'bg-blue-600 text-white shadow-md scale-105' : 'text-gray-500 hover:text-gray-800'}`}
|
||||
>
|
||||
Criança
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Odontogram Grid */}
|
||||
<div className="relative border-t border-b border-gray-50 py-10">
|
||||
{/* Visual Separators */}
|
||||
<div className="absolute left-1/2 top-4 bottom-4 w-[2px] bg-gray-200 -translate-x-1/2"></div>
|
||||
<div className="absolute top-1/2 left-4 right-4 h-[2px] bg-gray-200 -translate-y-1/2"></div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-16">
|
||||
{/* Q1 & Q2 (SUPERIOR) */}
|
||||
<div className="flex justify-end items-center gap-2 pr-2">
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q1)}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q1 : DENTES_CRIANCA.q1).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start items-center gap-2 pl-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q2 : DENTES_CRIANCA.q2).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q2)}
|
||||
</div>
|
||||
|
||||
{/* Q4 & Q3 (INFERIOR) */}
|
||||
<div className="flex justify-end items-center gap-2 pr-2">
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q4)}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q4 : DENTES_CRIANCA.q4).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start items-center gap-2 pl-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q3 : DENTES_CRIANCA.q3).map((d, i) => renderDenteBtn(d, i))}
|
||||
</div>
|
||||
{pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => selectArco('AI')}
|
||||
disabled={!!selectedProc && selectedProc.tipo_regiao !== 'ARCO'}
|
||||
className={`px-10 py-3 rounded-2xl border-2 font-black text-sm uppercase transition-all shadow-sm
|
||||
${selectedArco === 'AI' ? 'bg-amber-500 border-amber-600 text-white' :
|
||||
(!!selectedProc && selectedProc.tipo_regiao !== 'ARCO') ? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50' :
|
||||
'bg-white border-gray-100 text-gray-500 hover:border-amber-400'}`}
|
||||
>
|
||||
AI - Inferior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectArco('AS')}
|
||||
disabled={!!selectedProc && selectedProc.tipo_regiao !== 'ARCO'}
|
||||
className={`px-10 py-3 rounded-2xl border-2 font-black text-sm uppercase transition-all shadow-sm
|
||||
${selectedArco === 'AS' ? 'bg-amber-500 border-amber-600 text-white' :
|
||||
(!!selectedProc && selectedProc.tipo_regiao !== 'ARCO') ? 'bg-gray-50 border-gray-100 text-gray-300 cursor-not-allowed opacity-50' :
|
||||
'bg-white border-gray-100 text-gray-500 hover:border-amber-400'}`}
|
||||
>
|
||||
AS - Superior
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RASCUNHO LIST */}
|
||||
<div className="bg-white rounded-3xl border border-gray-100 overflow-hidden shadow-xl">
|
||||
<div className="bg-[#d49a4a] p-4 text-white font-black text-sm uppercase flex justify-between items-center tracking-widest">
|
||||
<span>PROCEDIMENTOS DO RASCUNHO</span>
|
||||
<div className="bg-white/20 px-3 py-1 rounded-full text-[10px]">{store.items.length}/20 ITENS</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{store.items.length === 0 && (
|
||||
<div className="py-16 text-center text-gray-300 font-bold uppercase flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center outline outline-gray-100 outline-offset-4">
|
||||
<AlertCircle size={32} />
|
||||
</div>
|
||||
NÃO HÁ ITENS ADICIONADOS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{store.items.map((item) => (
|
||||
<div key={item.id} className="border border-gray-100 rounded-2xl p-5 bg-gray-50/30 hover:bg-white transition-all shadow-sm group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="bg-blue-600 text-white text-[10px] font-black px-2 py-1 rounded-md uppercase shadow-sm">COD {item.codigoTUSS}</span>
|
||||
<h4 className="font-black text-gray-900 text-sm uppercase tracking-tight">{item.descricao}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase">
|
||||
<Users size={12} /> {store.paciente?.nome}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => store.removeItem(item.id)} className="text-gray-300 hover:text-red-600 p-2 transition-colors">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 text-[11px] font-black text-gray-500 uppercase tracking-wider mb-4 border-t border-gray-100 pt-4">
|
||||
<div>DENTE: <span className="text-blue-600 ml-1">{item.dente || '--'}</span></div>
|
||||
<div>ARCO: <span className="text-amber-600 ml-1">{item.arco || '--'}</span></div>
|
||||
<div>FACE: <span className="text-gray-900 ml-1">{item.face || '--'}</span></div>
|
||||
<div>VALOR: <span className="text-green-600 ml-1 text-xs">R$ {item.valorTotal.toFixed(2)}</span></div>
|
||||
</div>
|
||||
|
||||
{item.observacao && (
|
||||
<div className="text-[11px] text-gray-500 bg-white p-3 border border-gray-100 rounded-xl leading-relaxed">
|
||||
<span className="font-black text-gray-400 uppercase mr-2 opacity-50">OBS:</span> {item.observacao}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-6 border-t-2 border-dotted border-gray-200 flex justify-between items-center">
|
||||
<button className="flex items-center gap-2 text-blue-600 font-black text-[11px] uppercase hover:bg-blue-50 px-4 py-2 rounded-xl transition-all">
|
||||
<Camera size={16} /> ANEXAR DOCUMENTOS
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] mb-1">TOTAL ACUMULADO</p>
|
||||
<h3 className="text-3xl font-black text-gray-900 p-2 bg-gray-50 rounded-2xl border border-gray-100 shadow-inner">R$ {totalGeral.toFixed(2)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: WORKFLOW SELECTORS */}
|
||||
<div className="col-span-12 lg:col-span-4 space-y-4 sticky top-6">
|
||||
|
||||
{/* STEP 1: PACIENTE */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="flex items-center gap-3 text-blue-600 font-black text-base uppercase tracking-wider">
|
||||
<Users size={20} strokeWidth={3} /> 01. Paciente
|
||||
</div>
|
||||
<select
|
||||
value={store.paciente?.id || ''}
|
||||
onChange={(e) => {
|
||||
const p = pacientes.find(p => p.id === e.target.value);
|
||||
store.setPaciente(p || null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-blue-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O BENEFICIÁRIO</option>
|
||||
{pacientes.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-5">
|
||||
<div className="flex items-center gap-3 text-orange-500 font-black text-base uppercase tracking-wider">
|
||||
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedEsp}
|
||||
onChange={(e) => {
|
||||
setSelectedEsp(e.target.value);
|
||||
setSelectedProc(null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-orange-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A ÁREA...</option>
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedEsp && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
}}
|
||||
className="w-full p-4 bg-white border-2 border-orange-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedProc && (
|
||||
<div className="bg-orange-50 p-3 rounded-xl border border-orange-100 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<p className="text-[9px] font-black text-orange-600 uppercase mb-1">REGRAS DE SELEÇÃO</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-bold text-orange-800 flex items-center gap-1">
|
||||
<ChevronRight size={10} />
|
||||
{selectedProc.tipo_regiao === 'DENTE' ? 'EXIGE DENTE(S)' : selectedProc.tipo_regiao === 'ARCO' ? 'EXIGE ARCO (AI/AS)' : 'PROCEDIMENTO GERAL'}
|
||||
</span>
|
||||
{selectedProc.exige_face && (
|
||||
<span className="text-[10px] font-bold text-red-600 flex items-center gap-1">
|
||||
<ChevronRight size={10} /> EXIGE FACE CLINICA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 3: DENTISTA */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
|
||||
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
|
||||
</div>
|
||||
<select
|
||||
value={store.dentista?.id || ''}
|
||||
onChange={(e) => {
|
||||
const d = dentistas.find(d => d.id === e.target.value);
|
||||
store.setDentista(d || null);
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O PROFISSIONAL</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* STEP 4: DETALHES (FACE/OBS) */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-xl space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex justify-between">
|
||||
<span>Face (O, M, D, V, L, P)</span>
|
||||
{selectedProc?.exige_face && <span className="text-red-500 font-black animate-pulse">* OBRIGATÓRIO</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={face}
|
||||
onChange={e => setFace(e.target.value.toUpperCase())}
|
||||
disabled={selectedProc?.tipo_regiao === 'GERAL' && !selectedProc?.exige_face}
|
||||
className={`w-full p-4 border-2 rounded-2xl text-sm font-black outline-none transition-all
|
||||
${selectedProc?.exige_face ? 'bg-red-50 border-red-100 focus:border-red-400' : 'bg-gray-50 border-gray-100 focus:border-amber-400'}
|
||||
`}
|
||||
placeholder={selectedProc?.exige_face ? "DIGITE AS FACES..." : "OPCIONAL"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 italic">Observações Clínicas</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={obs}
|
||||
onChange={e => setObs(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] h-20 focus:border-amber-400 outline-none transition-all"
|
||||
/>
|
||||
<PlusCircle size={24} className="absolute bottom-4 right-4 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div className="grid grid-cols-4 gap-4 p-2">
|
||||
<button
|
||||
onClick={handleAddItem}
|
||||
className="col-span-3 bg-[#2d6a4f] text-white py-5 rounded-3xl font-black text-lg uppercase shadow-2xl shadow-green-200 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
ADICIONAR ITEM <ChevronRight className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="col-span-1 bg-gray-900 text-white py-5 rounded-3xl flex items-center justify-center shadow-xl hover:bg-black active:scale-95 transition-all"
|
||||
title="FINALIZAR GTO"
|
||||
>
|
||||
<Check size={32} strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
ShieldCheck,
|
||||
BarChart3,
|
||||
Users,
|
||||
Smartphone,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
X,
|
||||
Stethoscope,
|
||||
Zap,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const FeatureCard: React.FC<{ icon: React.ReactNode; title: string; description: string }> = ({ icon, title, description }) => (
|
||||
<div className="bg-white/70 backdrop-blur-md p-8 rounded-3xl border border-white/50 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_50px_rgba(8,112,184,0.1)] transition-all duration-500 group">
|
||||
<div className="w-14 h-14 bg-blue-600/10 rounded-2xl flex items-center justify-center mb-6 group-hover:bg-blue-600 group-hover:text-white transition-all duration-500 text-blue-600">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 uppercase tracking-tight">{title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LandingPage: React.FC<{ onGetStarted: () => void }> = ({ onGetStarted }) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const heroImage = "https://images.unsplash.com/photo-1629909613654-28e377c37b09?q=80&w=2070&auto=format&fit=crop"; // Placeholder logic, will use local if needed later
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] font-sans selection:bg-blue-100 selection:text-blue-900 overflow-x-hidden">
|
||||
{/* Navigation */}
|
||||
<nav className={`fixed top-0 left-0 right-0 z-[100] transition-all duration-500 ${scrolled ? 'bg-white/80 backdrop-blur-xl border-b border-gray-100 py-3 shadow-sm' : 'bg-transparent py-6'}`}>
|
||||
<div className="max-w-7xl mx-auto px-6 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-200">
|
||||
<Stethoscope className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-2xl font-black text-gray-900 tracking-tighter uppercase italic">Score<span className="text-blue-600">Odonto</span></span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<a href="#features" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Recursos</a>
|
||||
<a href="#solutions" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Soluções</a>
|
||||
<a href="#mobile" className="text-sm font-bold text-gray-600 hover:text-blue-600 transition-colors uppercase tracking-widest">Mobile</a>
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-gray-900 text-white px-6 py-3 rounded-full text-xs font-black uppercase tracking-widest hover:bg-blue-600 hover:shadow-xl hover:shadow-blue-200 transition-all active:scale-95"
|
||||
>
|
||||
Começar Agora
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button className="md:hidden p-2 text-gray-900" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
{mobileMenuOpen ? <X size={28} /> : <Menu size={28} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-40 overflow-hidden">
|
||||
{/* Abstract Backgrounds */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-blue-50/50 to-transparent -z-10 translate-x-20 skew-x-12"></div>
|
||||
<div className="absolute top-40 left-10 w-72 h-72 bg-blue-400/10 rounded-full blur-3xl -z-10"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-8">
|
||||
|
||||
|
||||
<h1 className="text-6xl lg:text-7xl font-black text-gray-900 leading-[1.05] tracking-tighter">
|
||||
O Futuro da Sua Clínica é <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600 underline decoration-blue-200 underline-offset-8">Inteligente</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 leading-relaxed max-w-lg font-medium">
|
||||
Maximize a produtividade com gestão financeira, agenda com arraste e solte, e controle absoluto de tratamentos em uma plataforma única e elegante.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-blue-600 text-white px-10 py-5 rounded-2xl text-sm font-black uppercase tracking-widest shadow-2xl shadow-blue-200 hover:bg-blue-700 hover:-translate-y-1 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Acessar Plataforma <ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div className="flex items-center gap-4 py-2 px-4">
|
||||
<div className="flex -space-x-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="w-10 h-10 rounded-full border-2 border-white bg-gray-200 flex items-center justify-center text-[10px] font-bold overflow-hidden shadow-sm">
|
||||
<img src={`https://i.pravatar.cc/100?img=${i + 10}`} alt="User" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-bold uppercase tracking-tight">
|
||||
<span className="text-gray-900">+400 Clínicas</span> <br /> que já otimizaram processos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative z-10 rounded-[3rem] overflow-hidden shadow-[0_50px_100px_-20px_rgba(0,0,0,0.2)] border-8 border-white group">
|
||||
<img
|
||||
src="/dental_landing_hero.png"
|
||||
alt="Interface ScoreOdonto"
|
||||
className="w-full h-auto transform group-hover:scale-105 transition-all duration-700"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "https://images.unsplash.com/photo-1594832284143-588b813ed334?q=80&w=2070&auto=format&fit=crop";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent flex items-bottom p-10 opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<p className="mt-auto text-white font-bold uppercase text-xs tracking-widest">Interface Real: Dashboard Analítico</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Element 1: Google Agenda */}
|
||||
<div className="absolute -top-10 -right-10 z-20 bg-white p-6 rounded-3xl shadow-2xl border border-blue-50 animate-bounce transition-all duration-1000 hidden md:block" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 text-blue-600 rounded-2xl">
|
||||
<Calendar size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Sincronização Total</div>
|
||||
<div className="text-lg font-black text-gray-900 tracking-tight">Google Agenda</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Element 2: WhatsApp CRM */}
|
||||
<div className="absolute -bottom-10 -left-10 z-20 bg-white p-6 rounded-3xl shadow-2xl border border-green-50 animate-bounce transition-all duration-1000">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 text-green-600 rounded-2xl">
|
||||
<MessageCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Comunicação</div>
|
||||
<div className="text-lg font-black text-gray-900 tracking-tight">CRM WhatsApp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof Bar */}
|
||||
<div className="bg-white border-y border-gray-100 py-10">
|
||||
<div className="max-w-7xl mx-auto px-6 overflow-hidden">
|
||||
<p className="text-center text-[10px] font-black text-gray-400 uppercase tracking-[0.3em] mb-10">Conectado com os principais convênios</p>
|
||||
<div className="flex justify-between items-center opacity-30 grayscale gap-10">
|
||||
<span className="text-2xl font-black italic">CASSEMS</span>
|
||||
<span className="text-2xl font-black italic">UNIMED</span>
|
||||
<span className="text-2xl font-black italic">BRADESCO</span>
|
||||
<span className="text-2xl font-black italic">SULAMÉRICA</span>
|
||||
<span className="text-2xl font-black italic">AMIL DENTAL</span>
|
||||
<span className="text-2xl font-black italic">REDE DENTAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-32 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-3xl mx-auto mb-20 space-y-4">
|
||||
<h2 className="text-4xl lg:text-5xl font-black text-gray-900 leading-tight tracking-tighter uppercase">Todo o Controle na Sua Mão</h2>
|
||||
<p className="text-lg text-gray-600 font-medium">Focamos na tecnologia para que você foque no que mais importa: o sorriso do seu paciente.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<FeatureCard
|
||||
icon={<Calendar size={28} />}
|
||||
title="Agenda 4.0"
|
||||
description="Controle total com arraste e solte, cores por profissional e sincronização em tempo real."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<ShieldCheck size={28} />}
|
||||
title="GTO Segura"
|
||||
description="Lançamento simplificado de guias de convênio com validação automática de erros."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users size={28} />}
|
||||
title="Gestão de Pacientes"
|
||||
description="CRM odontológico completo com histórico clínico, anexos e ortodontia avançada."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BarChart3 size={28} />}
|
||||
title="BI Financeiro"
|
||||
description="Gráficos de produtividade, fluxo de caixa e gestão de taxas de operadoras."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-7xl mx-auto bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[3rem] p-12 lg:p-24 overflow-hidden relative shadow-2xl shadow-blue-300">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-black/10 rounded-full translate-y-1/2 -translate-x-1/2 blur-3xl"></div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-3xl mx-auto">
|
||||
<h2 className="text-4xl lg:text-6xl font-black text-white leading-tight tracking-tighter uppercase italic">
|
||||
Sua Clínica Elevada ao <span className="bg-white text-blue-600 px-4 py-1 rounded-2xl not-italic">Próximo Nível</span>
|
||||
</h2>
|
||||
<p className="text-blue-100 text-xl font-medium">
|
||||
Junte-se a centenas de profissionais que já transformaram a gestão de seus consultórios. Comece agora gratuitamente.
|
||||
</p>
|
||||
<div className="pt-6">
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="bg-white text-blue-600 px-12 py-6 rounded-3xl text-sm font-black uppercase tracking-widest shadow-xl hover:bg-gray-50 hover:-translate-y-1 transition-all active:scale-95"
|
||||
>
|
||||
Testar Demonstrativo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 border-t border-gray-100 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-6 grid md:grid-cols-4 gap-12">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Stethoscope className="text-white" size={18} />
|
||||
</div>
|
||||
<span className="text-xl font-black text-gray-900 tracking-tighter uppercase italic">Score<span className="text-blue-600">Odonto</span></span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
A plataforma líder em gestão odontológica para clínicas que buscam excelência e eficiência.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Smartphone size={16} /></div>
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Users size={16} /></div>
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center cursor-pointer"><Zap size={16} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Produto</h4>
|
||||
<ul className="space-y-3 text-sm text-gray-500 font-bold uppercase tracking-tight">
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Funcionalidades</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Segurança</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">API</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Mobile</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Empresa</h4>
|
||||
<ul className="space-y-3 text-sm text-gray-500 font-bold uppercase tracking-tight">
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Sobre Nós</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Blog</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Carreiras</li>
|
||||
<li className="hover:text-blue-600 cursor-pointer transition-colors">Contato</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-black text-gray-900 uppercase tracking-widest">Suporte</h4>
|
||||
<div className="p-4 bg-gray-50 rounded-2xl border border-gray-100">
|
||||
<p className="text-xs text-gray-500 font-bold uppercase mb-2">Central de Ajuda</p>
|
||||
<a href="mailto:suporte@scoreodonto.com" className="text-sm font-black text-blue-600 hover:underline">suporte@scoreodonto.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mt-20 pt-10 border-t border-gray-50 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">© 2026 ScoreOdonto. Todos os direitos reservados.</p>
|
||||
<div className="flex gap-8 text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||||
<span className="hover:text-gray-900 cursor-pointer">Termos</span>
|
||||
<span className="hover:text-gray-900 cursor-pointer">Privacidade</span>
|
||||
<span className="hover:text-gray-900 cursor-pointer">Cookies</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Filter, MessageSquare, UserPlus, CheckCircle, X, Loader2, Plus, ChevronDown } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Lead, Dentista } from '../types.ts';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="col-span-3 flex justify-center items-center py-10">
|
||||
<Loader2 className="animate-spin text-blue-600" size={32} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorDisplay = ({ error }: { error: Error }) => (
|
||||
<div className="col-span-3 text-center py-10 text-red-500 uppercase font-bold text-sm">
|
||||
ERRO AO CARREGAR LEADS: {error.message}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NovoLeadModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState({ nome: '', sobrenome: '', whatsapp: '', tipoInteresse: 'Particular' });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.nome || !form.whatsapp) {
|
||||
toast.error('PREENCHA NOME E WHATSAPP.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await HybridBackend.saveLead({
|
||||
...form,
|
||||
status: 'Novo',
|
||||
dataCadastro: new Date().toISOString(),
|
||||
} as any);
|
||||
toast.success('LEAD CADASTRADO!');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('ERRO AO CADASTRAR LEAD.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-green-50 to-white">
|
||||
<h2 className="text-base font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-green-500" /> CADASTRAR NOVO LEAD
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl mx-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">NOME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.nome}
|
||||
onChange={e => setForm(f => ({ ...f, nome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
||||
placeholder="NOME"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">SOBRENOME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sobrenome}
|
||||
onChange={e => setForm(f => ({ ...f, sobrenome: e.target.value.toUpperCase() }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm font-semibold uppercase-input"
|
||||
placeholder="SOBRENOME"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">WHATSAPP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.whatsapp}
|
||||
onChange={e => setForm(f => ({ ...f, whatsapp: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm"
|
||||
placeholder="(XX) XXXXX-XXXX"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase">TIPO DE INTERESSE</label>
|
||||
<select
|
||||
value={form.tipoInteresse}
|
||||
onChange={e => setForm(f => ({ ...f, tipoInteresse: e.target.value }))}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg p-2.5 text-sm bg-white uppercase-select"
|
||||
>
|
||||
<option value="Particular">PARTICULAR</option>
|
||||
<option value="Plano">PLANO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-6">
|
||||
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-sm uppercase">CANCELAR</button>
|
||||
<button type="submit" className="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg text-sm uppercase flex items-center gap-2">
|
||||
<UserPlus size={16} /> CADASTRAR
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
export const LeadsView: React.FC = () => {
|
||||
const { data: leads, isLoading, error, refresh } = useHybridBackend<Lead[]>(HybridBackend.getLeads);
|
||||
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
|
||||
const [isConvertModalOpen, setIsConvertModalOpen] = useState(false);
|
||||
const [isNovoLeadOpen, setIsNovoLeadOpen] = useState(false);
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('TODOS');
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Effect to handle Esc key to close modal
|
||||
useEffect(() => {
|
||||
if (!isConvertModalOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsConvertModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isConvertModalOpen]);
|
||||
|
||||
const handleConvert = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedLead) return;
|
||||
|
||||
try {
|
||||
// Mocking the conversion as the backend method is not yet implemented
|
||||
toast.info(`FUNCIONALIDADE DE CONVERSÃO PARA ${selectedLead.nome} EM DESENVOLVIMENTO.`);
|
||||
setIsConvertModalOpen(false);
|
||||
// In a real scenario, this would create a patient and an appointment
|
||||
} catch {
|
||||
toast.error("FALHA AO CONVERTER LEAD.");
|
||||
}
|
||||
};
|
||||
|
||||
const filterOptions = ['TODOS', 'NOVO', 'CONVERTIDO'];
|
||||
|
||||
const filteredLeads = leads?.filter(lead => {
|
||||
if (filterStatus === 'TODOS') return true;
|
||||
return lead.status?.toUpperCase() === filterStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="GESTÃO DE LEADS">
|
||||
<div className="flex gap-2">
|
||||
{/* Filter Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(o => !o)}
|
||||
className="bg-white border border-gray-300 text-gray-600 px-3 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-gray-50 uppercase font-bold"
|
||||
>
|
||||
<Filter size={16} />
|
||||
{filterStatus}
|
||||
<ChevronDown size={14} className={`transition-transform ${isFilterOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isFilterOpen && (
|
||||
<div className="absolute right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[140px]">
|
||||
{filterOptions.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { setFilterStatus(opt); setIsFilterOpen(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm font-bold uppercase hover:bg-gray-50 ${filterStatus === opt ? 'text-blue-600 bg-blue-50' : 'text-gray-700'}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Lead Button */}
|
||||
<button
|
||||
onClick={() => setIsNovoLeadOpen(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm flex items-center gap-2 hover:bg-green-700 uppercase font-bold shadow-sm"
|
||||
>
|
||||
<Plus size={16} /> NOVO LEAD
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{error && <ErrorDisplay error={error} />}
|
||||
{filteredLeads && filteredLeads.map(lead => (
|
||||
<div key={lead.id} className={`bg-white rounded-xl shadow-sm border ${lead.status === 'Novo' ? 'border-amber-200 bg-amber-50/30' : 'border-gray-200'} p-6 relative`}>
|
||||
{lead.status === 'Novo' && (
|
||||
<div className="absolute top-4 right-4 w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 uppercase">{lead.nome} {lead.sobrenome}</h3>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1 mt-1 font-medium">
|
||||
<MessageSquare size={14} /> {lead.whatsapp}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${lead.tipoInteresse === 'Particular' ? 'bg-orange-100 text-orange-800' : 'bg-purple-100 text-purple-800'}`}>
|
||||
{lead.tipoInteresse}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-6 font-bold uppercase">
|
||||
CADASTRADO EM: {new Date(lead.dataCadastro).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{lead.status !== 'Convertido' ? (
|
||||
<button
|
||||
onClick={() => { setSelectedLead(lead); setIsConvertModalOpen(true); }}
|
||||
className="w-full py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center justify-center gap-2 font-bold transition-colors uppercase text-xs"
|
||||
>
|
||||
<UserPlus size={18} /> AGENDAR E CADASTRAR
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full py-2 bg-gray-100 text-gray-500 rounded-lg flex items-center justify-center gap-2 font-bold uppercase text-xs">
|
||||
<CheckCircle size={18} /> CONVERTIDO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredLeads?.length === 0 && !isLoading && (
|
||||
<div className="col-span-3 text-center py-10 text-gray-400 uppercase font-bold text-sm">
|
||||
NENHUM LEAD ENCONTRADO.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConvertModalOpen && selectedLead && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-blue-50 to-white">
|
||||
<h3 className="text-lg font-bold text-gray-800 uppercase flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-blue-500" /> CONVERTER LEAD
|
||||
</h3>
|
||||
<button onClick={() => setIsConvertModalOpen(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
||||
<form onSubmit={handleConvert} className="space-y-4 max-w-2xl mx-auto">
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-4 text-xs text-blue-800 border border-blue-100 font-bold uppercase">
|
||||
AO SALVAR, O SISTEMA IRÁ AUTOMATICAMENTE <strong>CADASTRAR O PACIENTE</strong> E CRIAR O AGENDAMENTO.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">PACIENTE</label>
|
||||
<input disabled value={`${selectedLead.nome} ${selectedLead.sobrenome}`.toUpperCase()} className="w-full bg-gray-100 border border-gray-300 rounded-lg p-2.5 text-gray-500 uppercase font-bold" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DATA AGENDAMENTO</label>
|
||||
<input type="date" required className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">HORA</label>
|
||||
<input type="time" required className="w-full border border-gray-300 rounded-lg p-2.5 font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 uppercase">DENTISTA RESPONSÁVEL</label>
|
||||
<select className="w-full border border-gray-300 rounded-lg p-2.5 bg-white uppercase-select font-semibold">
|
||||
{dentistas && dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-3 rounded-lg font-bold hover:bg-green-700 uppercase text-sm shadow-sm transition-all">
|
||||
CONFIRMAR AGENDAMENTO
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNovoLeadOpen && (
|
||||
<NovoLeadModal onClose={() => setIsNovoLeadOpen(false)} onSaved={refresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, Lock, User, ArrowRight, ShieldCheck } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await HybridBackend.login(email, password);
|
||||
if (success) {
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
setError('CREDENCIAIS INVÁLIDAS. VERIFIQUE EMAIL E SENHA.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('ERRO DE CONEXÃO. TENTE NOVAMENTE.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
|
||||
<div className="bg-blue-600 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-white mx-auto backdrop-blur-sm mb-4">
|
||||
<Database size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white uppercase tracking-wide">SCOREODONTO</h1>
|
||||
<p className="text-blue-100 text-xs font-bold uppercase mt-1">SISTEMA INTEGRADO MYSQL + GOOGLE</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<h2 className="text-lg font-bold text-gray-800 uppercase mb-6 text-center flex items-center justify-center gap-2">
|
||||
<Lock size={18} className="text-blue-600"/> ACESSO RESTRITO
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">USUÁRIO / EMAIL</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-medium"
|
||||
placeholder="ex: usuario@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">SENHA DE ACESSO</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-medium"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-600 text-xs font-bold uppercase rounded-lg border border-red-100 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold uppercase flex items-center justify-center gap-2 transition-all shadow-lg hover:shadow-xl disabled:opacity-70 disabled:cursor-not-allowed mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
<span>VERIFICANDO...</span>
|
||||
) : (
|
||||
<>ENTRAR NO SISTEMA <ArrowRight size={18} /></>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-gray-400 text-[10px] font-bold uppercase">
|
||||
<ShieldCheck size={12}/> AMBIENTE SEGURO E CRIPTOGRAFADO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user