Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc006221de | |||
| d77679a849 | |||
| 93bf37340f | |||
| 471c2c2603 | |||
| 5049ce9b8b | |||
| 0f50835114 | |||
| f2eb90ccc8 | |||
| a991f1fe2c | |||
| 6e81718725 | |||
| deaddfbf68 | |||
| efa9553536 | |||
| 96b857e408 | |||
| a89eed3ba6 |
+65
-34
@@ -1,24 +1,28 @@
|
||||
name: build-and-push
|
||||
name: build-and-promote
|
||||
|
||||
# Etapa 5: a cada push na main (ou tag vX.Y.Z), builda as imagens com o carimbo
|
||||
# de versão e envia ao Container Registry do Gitea. NÃO faz deploy (Etapa 6 separada).
|
||||
# Passo 5 — Build Once → Promote → Deploy:
|
||||
# • push na main → job BUILD: builda 1x e publica por COMMIT (:<sha> + :latest). NÃO deploya.
|
||||
# • tag vX.Y.Z → job PROMOTE: re-tagueia a imagem do commit como :vX.Y.Z (SEM rebuild),
|
||||
# valida integridade e deploya na VPS1.
|
||||
# A versão semântica NÃO entra no build: nasce da tag e é injetada no deploy em runtime
|
||||
# (docker-compose.prod.yml). Não há mais APP_VERSION/constants.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
# ───────────────────────── BUILD (só em push de branch) ─────────────────────────
|
||||
build:
|
||||
# Roda direto no host VPS4 (runner registrado com label de host + acesso ao Docker).
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Calcular versão/commit/data
|
||||
- name: Vars (commit + data)
|
||||
id: vars
|
||||
run: |
|
||||
echo "version=$(grep -oE 'V[0-9]+\.[0-9]+\.[0-9]+' frontend/constants.ts | head -1 | tr 'V' 'v')" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -27,65 +31,92 @@ jobs:
|
||||
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin
|
||||
else
|
||||
echo "REGISTRY_TOKEN não definido — usando a credencial já presente no host (~/.docker/config.json)."
|
||||
echo "REGISTRY_TOKEN ausente — usando a credencial já presente no host."
|
||||
fi
|
||||
|
||||
- name: Build (compose, com carimbo de versão)
|
||||
- name: Build (carimba commit/data; versão vem da tag no deploy, não aqui)
|
||||
env:
|
||||
# Fixa o nome do projeto p/ a imagem buildada bater com o passo de tag
|
||||
# (em modo host o diretório do checkout muda o project name por padrão).
|
||||
COMPOSE_PROJECT_NAME: scoreodontocom
|
||||
APP_VERSION: ${{ steps.vars.outputs.version }}
|
||||
GIT_COMMIT: ${{ steps.vars.outputs.commit }}
|
||||
BUILD_TIME: ${{ steps.vars.outputs.build_time }}
|
||||
APP_ENV: PROD
|
||||
run: docker compose build scoreodonto-backend scoreodonto-frontend
|
||||
|
||||
- name: Tag + push (com retry p/ 499 transitório)
|
||||
- name: Publicar por COMMIT (:<sha> + :latest), com retry p/ 499
|
||||
run: |
|
||||
set -e
|
||||
V="${{ steps.vars.outputs.version }}"
|
||||
REG=git.clube67.com/ruicesar
|
||||
C="${{ steps.vars.outputs.commit }}"
|
||||
for app in backend frontend; do
|
||||
SRC="scoreodontocom-scoreodonto-${app}:latest"
|
||||
DST="git.clube67.com/ruicesar/scoreodonto-${app}"
|
||||
docker tag "$SRC" "$DST:$V"
|
||||
DST="${REG}/scoreodonto-${app}"
|
||||
docker tag "$SRC" "$DST:$C"
|
||||
docker tag "$SRC" "$DST:latest"
|
||||
for tag in "$V" "$C" "latest"; do
|
||||
for tag in "$C" "latest"; do
|
||||
n=0
|
||||
until docker push "$DST:$tag"; do
|
||||
n=$((n+1)); [ $n -ge 4 ] && { echo "push falhou após 4 tentativas: $DST:$tag"; exit 1; }
|
||||
n=$((n+1)); [ $n -ge 4 ] && { echo "push falhou: $DST:$tag"; exit 1; }
|
||||
echo "retry $n (499 transitório) em $DST:$tag"; sleep 3
|
||||
done
|
||||
done
|
||||
done
|
||||
echo "Publicado: scoreodonto-{backend,frontend}:$C (+ :latest). Push NÃO deploya."
|
||||
|
||||
- name: Resumo
|
||||
run: |
|
||||
echo "Imagens publicadas:"
|
||||
echo " git.clube67.com/ruicesar/scoreodonto-backend:${{ steps.vars.outputs.version }}"
|
||||
echo " git.clube67.com/ruicesar/scoreodonto-frontend:${{ steps.vars.outputs.version }}"
|
||||
echo "Deploy só acontece em TAG (job deploy)."
|
||||
|
||||
# Deploy automático SÓ em tags vX.Y.Z (a tag = 'aprovado para produção').
|
||||
# Push na main NÃO deploya — só builda. Etapa 6.
|
||||
deploy:
|
||||
needs: build
|
||||
# ─────────────────────── PROMOTE (só em tag vX.Y.Z) ───────────────────────
|
||||
promote:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout (na tag)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Promover a tag para produção (VPS1)
|
||||
- name: Vars (tag + commit)
|
||||
id: vars
|
||||
run: |
|
||||
echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login no registry
|
||||
run: |
|
||||
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin
|
||||
fi
|
||||
|
||||
- name: Integridade — a imagem do commit existe? (nunca buildar na tag)
|
||||
run: |
|
||||
set -e
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
echo "Promovendo $TAG para a produção (VPS1)..."
|
||||
# Garante que o mecanismo de deploy está atualizado na VPS1
|
||||
REG=git.clube67.com/ruicesar
|
||||
C="${{ steps.vars.outputs.commit }}"
|
||||
for app in backend frontend; do
|
||||
if ! docker buildx imagetools inspect "${REG}/scoreodonto-${app}:$C" >/dev/null 2>&1; then
|
||||
echo "ERRO: ${REG}/scoreodonto-${app}:$C não existe no registry."
|
||||
echo " Faça 'git push origin main' (que builda o commit $C) ANTES de criar a tag."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: backend:$C e frontend:$C presentes — mesmo commit $C (front+back atômicos)."
|
||||
|
||||
- name: Promover — re-tag commit → versão (SEM rebuild) + conferir digest
|
||||
run: |
|
||||
set -e
|
||||
REG=git.clube67.com/ruicesar
|
||||
C="${{ steps.vars.outputs.commit }}"; V="${{ steps.vars.outputs.version }}"
|
||||
for app in backend frontend; do
|
||||
DST="${REG}/scoreodonto-${app}"
|
||||
docker buildx imagetools create -t "$DST:$V" "$DST:$C"
|
||||
dC=$(docker buildx imagetools inspect "$DST:$C" --format '{{.Manifest.Digest}}')
|
||||
dV=$(docker buildx imagetools inspect "$DST:$V" --format '{{.Manifest.Digest}}')
|
||||
[ "$dC" = "$dV" ] || { echo "ERRO: digest divergiu em $app ($V=$dV != $C=$dC)"; exit 1; }
|
||||
echo "$app: $V -> $C (digest $dV)"
|
||||
done
|
||||
echo "Promovido sem rebuild: a imagem :$V E a imagem :$C (mesmo artefato)."
|
||||
|
||||
- name: Deploy na VPS1 (pull :versao + up --no-build; versao injetada em runtime)
|
||||
run: |
|
||||
set -e
|
||||
V="${{ steps.vars.outputs.version }}"
|
||||
scp -o StrictHostKeyChecking=accept-new \
|
||||
docker-compose.prod.yml deploy-prod.sh \
|
||||
deploy@10.99.0.1:/home/deploy/stack/scoreodonto.com/
|
||||
ssh -o StrictHostKeyChecking=accept-new deploy@10.99.0.1 \
|
||||
"/home/deploy/stack/scoreodonto.com/deploy-prod.sh $TAG"
|
||||
"/home/deploy/stack/scoreodonto.com/deploy-prod.sh $V"
|
||||
|
||||
@@ -12,6 +12,9 @@ backend/test-db-users.js
|
||||
backend/test-db-users.cjs
|
||||
backend/pg-convert.js
|
||||
|
||||
# Override local de ambiente DEV (aponta o banco p/ pgdev) — NÃO versionar
|
||||
.env
|
||||
|
||||
# Dependências e caches locais
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
+421
-1
@@ -41,7 +41,7 @@ const ortoUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize:
|
||||
// =============================================================================
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost')
|
||||
ssl: process.env.DATABASE_URL && !/localhost|@pgdev/.test(process.env.DATABASE_URL)
|
||||
? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000
|
||||
@@ -181,6 +181,41 @@ const BUILD_INFO = {
|
||||
};
|
||||
app.get('/api/version', (req, res) => res.json(BUILD_INFO));
|
||||
|
||||
// Health check (público, sem auth) — status do backend + dependências.
|
||||
// Banco é crítico (down => HTTP 503); cache é opcional (down => "degraded", mas 200).
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const checks = {};
|
||||
try {
|
||||
const t = Date.now();
|
||||
await pool.query('SELECT 1');
|
||||
checks.database = { status: 'ok', latency_ms: Date.now() - t };
|
||||
} catch {
|
||||
checks.database = { status: 'down' };
|
||||
}
|
||||
try {
|
||||
if (redis) {
|
||||
const t = Date.now();
|
||||
await redis.ping();
|
||||
checks.cache = { status: 'ok', latency_ms: Date.now() - t };
|
||||
} else {
|
||||
checks.cache = { status: 'disabled' };
|
||||
}
|
||||
} catch {
|
||||
checks.cache = { status: 'down' };
|
||||
}
|
||||
const dbOk = checks.database.status === 'ok';
|
||||
const cacheDegraded = checks.cache.status === 'down';
|
||||
const status = !dbOk ? 'down' : (cacheDegraded ? 'degraded' : 'ok');
|
||||
res.status(dbOk ? 200 : 503).json({
|
||||
status,
|
||||
version: BUILD_INFO.version,
|
||||
commit: BUILD_INFO.commit,
|
||||
environment: BUILD_INFO.environment,
|
||||
uptime_s: Math.round(process.uptime()),
|
||||
checks,
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// SECURITY MIDDLEWARE: Tenant Guard
|
||||
// =============================================================================
|
||||
@@ -452,6 +487,36 @@ async function seedBiomedicina(clinicaId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral).
|
||||
// Garantia padrão de 12 meses por produto.
|
||||
const PROTESE_PRODUTOS_PADRAO = [
|
||||
{ nome: 'Coroa de Zircônia', preco: 350 },
|
||||
{ nome: 'Coroa Metalocerâmica', preco: 250 },
|
||||
{ nome: 'Coroa Provisória', preco: 80 },
|
||||
{ nome: 'Faceta / Laminado de Porcelana', preco: 400 },
|
||||
{ nome: 'Onlay / Inlay Cerâmico', preco: 300 },
|
||||
{ nome: 'Núcleo Metálico Fundido', preco: 90 },
|
||||
{ nome: 'Prótese Total (Dentadura)', preco: 600 },
|
||||
{ nome: 'Prótese Parcial Removível (PPR)', preco: 500 },
|
||||
{ nome: 'Coroa sobre Implante', preco: 600 },
|
||||
{ nome: 'Protocolo sobre Implante (arco)', preco: 1800 },
|
||||
{ nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 },
|
||||
{ nome: 'Provisório em Resina (unidade)', preco: 60 },
|
||||
];
|
||||
// Idempotente: só popula se o protético ainda não tem nenhum produto.
|
||||
async function seedProteseProdutos(proteticoId) {
|
||||
if (!proteticoId) return false;
|
||||
const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]);
|
||||
if (rows.length) return false;
|
||||
for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) {
|
||||
const p = PROTESE_PRODUTOS_PADRAO[i];
|
||||
await pool.query(
|
||||
"INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)",
|
||||
[`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- REGISTRO DE USUÁRIOS ---
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
|
||||
@@ -492,6 +557,8 @@ app.post('/api/register', async (req, res) => {
|
||||
workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }];
|
||||
// Biomédico já nasce com o catálogo de biomedicina estética no consultório dele.
|
||||
if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } }
|
||||
// Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia).
|
||||
if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } }
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
|
||||
@@ -3616,6 +3683,290 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) =>
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ═══ PRÓTESES · Ordem de Serviço (OS): clínica/dentista → laboratório/protético ═══
|
||||
// Cross-workspace (padrão das propostas): a clínica cria por tenantGuard (clinica_id origem);
|
||||
// o protético vê na "Bancada" por authGuard (protetico_id = destinatário).
|
||||
const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado'];
|
||||
|
||||
// Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo).
|
||||
async function proteseOsAccesso(os, uid) {
|
||||
if (!os) return { isLab: false, isClinic: false };
|
||||
const isLab = !!uid && os.protetico_id === uid;
|
||||
let isClinic = false;
|
||||
if (uid) {
|
||||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]);
|
||||
isClinic = rows.length > 0;
|
||||
}
|
||||
return { isLab, isClinic };
|
||||
}
|
||||
|
||||
// Um protético é "acessível" por uma clínica se for interno (vínculo) OU estiver no diretório.
|
||||
// Mesma regra do dropdown de Nova OS — usada para travar criação de OS e leitura de catálogo.
|
||||
async function proteticoAcessivelPelaClinica(proteticoId, clinicaId) {
|
||||
if (!proteticoId || !clinicaId) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $2
|
||||
WHERE u.id = $1 AND u.role = 'protetico'
|
||||
AND (v.clinica_id = $2 OR u.disponivel_diretorio = true)
|
||||
LIMIT 1`, [proteticoId, clinicaId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// Laboratórios/protéticos disponíveis para o dropdown da Nova OS:
|
||||
// protéticos com vínculo na clínica (internos) + protéticos no diretório (externos).
|
||||
app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.nome, u.email,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
WHERE u.role = 'protetico'
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
res.json(rows.map(r => ({ ...r, interno: !!r.interno })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Catálogo de produtos do protético (preço fixo, geral) ───────────────────
|
||||
// O protético gerencia o próprio catálogo (escopo por usuário). Lazy-seed na 1ª leitura.
|
||||
app.get('/api/protese/produtos', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
await seedProteseProdutos(uid); // idempotente — protéticos antigos ganham o catálogo padrão
|
||||
const { rows } = await pool.query('SELECT * FROM protese_produtos WHERE protetico_id=$1 AND deleted_at IS NULL ORDER BY ordem, nome', [uid]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Catálogo ATIVO de um laboratório (lido pela clínica ao criar a OS).
|
||||
app.get('/api/protese/laboratorios/:id/produtos', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
if (!await proteticoAcessivelPelaClinica(req.params.id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
await seedProteseProdutos(req.params.id);
|
||||
const { rows } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE protetico_id=$1 AND ativo=true AND deleted_at IS NULL ORDER BY ordem, nome', [req.params.id]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.post('/api/protese/produtos', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.nome) return res.status(400).json({ error: 'nome obrigatório.' });
|
||||
try {
|
||||
const id = `ppr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[id, req.authUser.userId, b.nome, Number(b.preco) || 0, Number(b.garantia_meses) || 12, b.ativo !== false, Number(b.ordem) || 0]);
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE protese_produtos SET nome=COALESCE($1,nome), preco=COALESCE($2,preco), garantia_meses=COALESCE($3,garantia_meses), ativo=COALESCE($4,ativo) WHERE id=$5 AND protetico_id=$6 AND deleted_at IS NULL',
|
||||
[b.nome ?? null, b.preco != null ? Number(b.preco) : null, b.garantia_meses != null ? Number(b.garantia_meses) : null, b.ativo ?? null, req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.delete('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('UPDATE protese_produtos SET deleted_at=NOW() WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Criar OS (clínica origem).
|
||||
app.post('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const b = req.body || {};
|
||||
if (!b.protetico_id) return res.status(400).json({ error: 'protetico_id (laboratório) obrigatório.' });
|
||||
try {
|
||||
const { rows: prot } = await pool.query("SELECT id, nome FROM usuarios WHERE id=$1 AND role='protetico'", [b.protetico_id]);
|
||||
if (!prot.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
// Só pode pedir OS a protético interno (vínculo) ou no diretório — mesma regra do dropdown.
|
||||
if (!await proteticoAcessivelPelaClinica(b.protetico_id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
// Produto do catálogo do laboratório = preço fixo (custo de lab) + garantia.
|
||||
let tipo = b.tipo || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null;
|
||||
if (b.produto_id) {
|
||||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, b.protetico_id]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
|
||||
produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12;
|
||||
}
|
||||
if (!tipo) return res.status(400).json({ error: 'Selecione um produto/tipo de prótese.' });
|
||||
// OS de garantia: refaz uma OS anterior sem novo custo (dentro da garantia).
|
||||
const ehGarantia = b.tipo_os === 'garantia' && !!b.origem_os_id;
|
||||
if (ehGarantia) custo = 0;
|
||||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const uid = req.authUser?.userId;
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`,
|
||||
[id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null,
|
||||
b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null,
|
||||
tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses,
|
||||
b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
|
||||
b.prazo_entrega || null, Number(b.valor) || 0, custo, uid]);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, req.clinicaId, 'OS criada', uid, await actorNome(uid)]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: id, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { protetico_id: b.protetico_id, tipo } });
|
||||
await notificarUsuario(b.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${tipo}). Veja na Bancada.`);
|
||||
wsBroadcast(req.clinicaId, 'protese', { entidadeId: id, acao: 'criou' });
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Lista da clínica origem (dentista/recepção).
|
||||
app.get('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const vals = [req.clinicaId];
|
||||
let where = 'clinica_id=$1 AND deleted_at IS NULL';
|
||||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||||
if (req.query.paciente_id) { vals.push(req.query.paciente_id); where += ` AND paciente_id=$${vals.length}`; }
|
||||
const { rows } = await pool.query(`SELECT * FROM protese_os WHERE ${where} ORDER BY created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Bancada do laboratório (protético): OS destinadas a mim, com flag de atraso.
|
||||
app.get('/api/protese/bancada', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const vals = [uid];
|
||||
let where = 'protetico_id=$1 AND deleted_at IS NULL';
|
||||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
|
||||
FROM protese_os WHERE ${where}
|
||||
ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Detalhe + histórico de eventos + fotos.
|
||||
app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const { rows: eventos } = await pool.query('SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: fotos } = await pool.query('SELECT id, original_name FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
res.json({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
eventos,
|
||||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
const novo = req.body?.status;
|
||||
if (!PROTESE_STATUS.includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const uid = req.authUser.userId;
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
if (os.status === 'cancelado' || os.status === 'entregue') return res.status(409).json({ error: `OS já está "${os.status}".` });
|
||||
// Entregue/cancelado são da clínica; estados de produção são do laboratório.
|
||||
const estadosClinica = ['entregue', 'cancelado'];
|
||||
if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' });
|
||||
if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' });
|
||||
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia).
|
||||
let garantiaAte = null, finId = os.financeiro_id || null;
|
||||
if (novo === 'entregue') {
|
||||
const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]);
|
||||
if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' });
|
||||
const meses = Number(os.garantia_meses) || 12;
|
||||
const { rows: gd } = await pool.query(`SELECT (CURRENT_DATE + ($1 || ' months')::interval)::date AS d`, [meses]);
|
||||
garantiaAte = gd[0].d;
|
||||
// Custo de laboratório → DESPESA (contas a pagar ao laboratório), idempotente. OS de garantia não cobra.
|
||||
if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
|
||||
finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao)
|
||||
VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`,
|
||||
[finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo),
|
||||
os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]);
|
||||
}
|
||||
}
|
||||
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id) WHERE id=$4', [novo, garantiaAte, finId, os.id]);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]);
|
||||
await registrarAudit(pool, { clinicaId: os.clinica_id, entidade: 'protese_os', entidadeId: os.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { financeiro_id: finId, garantia_ate: garantiaAte } });
|
||||
// Notifica a outra parte.
|
||||
const destino = isLab ? os.created_by : os.protetico_id;
|
||||
if (destino) await notificarUsuario(destino, `Prótese: ${novo}`, `A OS de ${os.paciente_nome || 'paciente'} (${os.tipo}) mudou para "${novo}".`, novo === 'entregue' ? 'sucesso' : 'info');
|
||||
wsBroadcast(os.clinica_id, 'protese', { entidadeId: os.id, acao: novo });
|
||||
res.json({ success: true, status: novo, financeiro_id: finId, garantia_ate: garantiaAte });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Anexar foto do trabalho (laboratório ou clínica).
|
||||
app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||||
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
|
||||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||||
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]);
|
||||
res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Stream da imagem (URL assinada — img tag não envia Authorization).
|
||||
app.get('/api/protese/os/fotos/:id/raw', async (req, res) => {
|
||||
try {
|
||||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||||
const { rows } = await pool.query('SELECT filename, mimetype FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).end();
|
||||
res.type(rows[0].mimetype || 'image/jpeg');
|
||||
res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); });
|
||||
} catch { res.status(404).end(); }
|
||||
});
|
||||
|
||||
// Excluir OS (só a clínica de origem; soft delete).
|
||||
app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT financeiro_id FROM protese_os WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
await pool.query('UPDATE protese_os SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]);
|
||||
// Estorna a DESPESA de laboratório vinculada (se houver).
|
||||
if (rows[0].financeiro_id) await pool.query('UPDATE financeiro SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: req.params.id, acao: 'excluiu', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { estornou: rows[0].financeiro_id || null } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// --- CONTRATOS ---
|
||||
app.get('/api/orcamentos', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -5142,6 +5493,75 @@ async function runMigrations() {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`,
|
||||
// ── PRÓTESES: Ordem de Serviço (clínica → laboratório/protético) ─────
|
||||
`CREATE TABLE IF NOT EXISTS protese_os (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT NOT NULL, -- clínica origem (quem pediu) — escopo tenantGuard
|
||||
laboratorio_id TEXT, -- workspace destino (futuro; null = roteia por protetico_id)
|
||||
protetico_id TEXT, -- usuário destinatário (a "bancada")
|
||||
protetico_nome TEXT,
|
||||
paciente_id TEXT,
|
||||
paciente_nome TEXT,
|
||||
dentista_id TEXT,
|
||||
dentista_nome TEXT,
|
||||
procedimento_id TEXT,
|
||||
agendamento_id TEXT,
|
||||
tipo TEXT, -- fixa | removível | total | sobre implante | provisório...
|
||||
material TEXT,
|
||||
cor TEXT, -- escala de cor
|
||||
dentes TEXT, -- FDI livre (ex: 11,21)
|
||||
observacoes TEXT,
|
||||
prazo_entrega DATE,
|
||||
valor NUMERIC DEFAULT 0, -- preço cobrado pela OS
|
||||
custo NUMERIC DEFAULT 0, -- custo de laboratório (elo futuro com comissão)
|
||||
status TEXT DEFAULT 'solicitado',-- solicitado|recebido|producao|prova|ajuste|finalizado|entregue|cancelado
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_clinica ON protese_os (clinica_id, status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_lab ON protese_os (protetico_id, status)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_evento (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
status TEXT,
|
||||
observacao TEXT,
|
||||
actor_id TEXT,
|
||||
actor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_evento ON protese_os_evento (os_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_foto (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
filename TEXT,
|
||||
mimetype TEXT,
|
||||
original_name TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_foto ON protese_os_foto (os_id)`,
|
||||
// Catálogo de produtos do protético (preço fixo, sem especialidade — geral de prótese) + garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS produto_id TEXT`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS tipo_os TEXT DEFAULT 'nova'`, // nova | garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS origem_os_id TEXT`, // OS original (em garantia)
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
nome TEXT NOT NULL,
|
||||
preco NUMERIC DEFAULT 0, -- preço fixo cobrado da clínica (= custo de laboratório)
|
||||
garantia_meses INTEGER DEFAULT 12,
|
||||
ativo BOOLEAN DEFAULT true,
|
||||
ordem INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
|
||||
|
||||
+2
-1
@@ -24,8 +24,9 @@ FILES="-f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
echo "──────────────────────────────────────────────────────────────"
|
||||
echo " Deploy ScoreOdonto → PRODUÇÃO tag: $TAG"
|
||||
echo " Versão exibida (/api/version) será injetada em runtime = $TAG (commit/build vêm da imagem)"
|
||||
echo "──────────────────────────────────────────────────────────────"
|
||||
echo "⚠️ Recomendado: backup do banco antes (dev/prod compartilham o banco):"
|
||||
echo "⚠️ Recomendado: backup do banco de PRODUÇÃO antes (migrações rodam no boot do backend):"
|
||||
echo " ssh deploy@10.99.0.3 'pg_dump -Fc scoreodonto -f ~/backups/scoreodonto_\$(date +%Y%m%d_%H%M).dump'"
|
||||
echo
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Estratégia de Banco DEV — Relatório + Plano de Implementação
|
||||
|
||||
> Documento de **decisão e plano** (16/jun/2026). A 1ª parte é o relatório técnico baseado em dados reais; a 2ª é o plano de implementação profissional, escalável para **todos os projetos atuais e futuros**.
|
||||
> Relacionado: `arquitetura.md`, `PENDENCIAS.md` (P0 isolar dev), `VERDADE-HOJE.md`.
|
||||
|
||||
---
|
||||
|
||||
# PARTE 1 — Relatório técnico (dados reais, read-only)
|
||||
|
||||
**Servidor (VPS3):** PostgreSQL **18.4** · **PostGIS 3.6.2** (suíte completa disponível).
|
||||
|
||||
**Bancos (8 no total, ~175 MB somados):**
|
||||
|
||||
| Banco | Tamanho | Projeto | DEV na VPS4? | Tabelas | Índices | PostGIS |
|
||||
|-------|--------:|---------|:------------:|--------:|--------:|:-------:|
|
||||
| newwhats | 91 MB | NewWhats (VPS1) | não | — | — | — |
|
||||
| giteadb | 22 MB | Gitea (infra) | não | — | — | não |
|
||||
| **scoreodonto** | **21 MB** | **ScoreOdonto** | **sim** | 104 | 168 | **SIM (3 geog)** |
|
||||
| mercado | 13 MB | Mercado (VPS1) | não | — | 187 | não |
|
||||
| **dental_images** | **11 MB** | **RX** | **sim** | 8 | 25 | não |
|
||||
| clube67 | 8.8 MB | Clube67 | não | — | — | — |
|
||||
| **subdominio** | **8 MB** | **Subdomínio** | **sim** | 3 | 7 | não |
|
||||
| postgres | 7.7 MB | default | — | — | — | — |
|
||||
|
||||
**Conclusões:**
|
||||
- Os 3 bancos de DEV (scoreodonto, dental_images, subdominio) somam **~40 MB**. São minúsculos.
|
||||
- **Só o ScoreOdonto usa PostGIS.** RX e Subdomínio não.
|
||||
- Sem dependências entre bancos. Sem dados de crescimento histórico (não medível sem superuser); porte indica crescimento lento.
|
||||
|
||||
**Veredito:** a proposta "mirror + dev por projeto" (6 containers PostgreSQL/PostGIS) é **viável mas exagerada** para esse porte. Um "mirror" *rodando* não agrega sobre um **template database** ou um **dump**; é só RAM e complexidade a mais (~300–600 MB RAM e 6× manutenção).
|
||||
|
||||
**Recomendação final:** **um único container PostGIS na VPS4** (rootless), multi-banco, com `*_ref` (referência/template, restaurado da VPS3) + `*_dev` (de trabalho, criado via `CREATE DATABASE ... TEMPLATE`). Reset instantâneo, custo ~50–100 MB RAM, manutenção mínima, e **escala para qualquer projeto novo**.
|
||||
|
||||
---
|
||||
|
||||
# PARTE 2 — Plano de implementação (profissional, escalável)
|
||||
|
||||
> Objetivo: desgrudar **todos** os ambientes DEV do banco de produção (VPS3), com um padrão único que serve projetos atuais e futuros. **Produção (VPS3/VPS1) nunca é alterada — só lida (pg_dump).**
|
||||
|
||||
## 2.1 Arquitetura
|
||||
|
||||
```
|
||||
VPS3 (produção, fonte da verdade)
|
||||
│ pg_dump (somente leitura, one-way)
|
||||
▼
|
||||
┌──────────────── VPS4 (rootless) — container único "pgdev" ───────────────┐
|
||||
│ postgis/postgis 18-3.6 (porta interna, rede 'soc', sem exposição pública) │
|
||||
│ │
|
||||
│ scoreodonto_ref ──TEMPLATE──► scoreodonto_dev ◄── backend dev ScoreOdonto │
|
||||
│ dental_images_ref ─TEMPLATE──► dental_images_dev ◄── backend dev RX │
|
||||
│ subdominio_ref ──TEMPLATE──► subdominio_dev ◄── backend dev Subdomínio │
|
||||
│ <novo>_ref ──TEMPLATE──► <novo>_dev ◄── (projeto futuro) │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **`*_ref`**: cópia fiel da produção (restaurada por `pg_dump`). Não se mexe — é a referência/"mirror".
|
||||
- **`*_dev`**: banco de trabalho, recriável do `_ref` em segundos via `TEMPLATE`. É onde rodam migrações e testes.
|
||||
- **Um container só** atende todos (Postgres lida com dezenas de bancos pequenos sem esforço). PostGIS coberto para quem precisa (ScoreOdonto); os demais simplesmente não usam.
|
||||
|
||||
## 2.2 Componentes (a criar em `/home/deploy/stack/pg-dev/`)
|
||||
|
||||
**a) `docker-compose.yml`** (container do banco DEV):
|
||||
```yaml
|
||||
services:
|
||||
pgdev:
|
||||
image: postgis/postgis:18-3.6 # confirmar tag p/ PG18+PostGIS3.6 (alt.: imresamu/postgis:18-3.6)
|
||||
container_name: pgdev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: devadmin
|
||||
POSTGRES_PASSWORD: ${PGDEV_PASSWORD}
|
||||
volumes:
|
||||
- pgdev-data:/var/lib/postgresql/data
|
||||
networks: [soc] # mesma rede dos backends de dev (acesso por DNS 'pgdev')
|
||||
# SEM ports: publicado → só acessível internamente (segurança)
|
||||
volumes:
|
||||
pgdev-data:
|
||||
networks:
|
||||
soc:
|
||||
external: true
|
||||
```
|
||||
|
||||
**b) `pg-dev-projects.conf`** (manifesto — adicionar 1 linha por projeto novo):
|
||||
```
|
||||
# nome_projeto : banco_origem_VPS3 : usuario_origem
|
||||
scoreodonto : scoreodonto : scoreodonto_user
|
||||
rx : dental_images : clube67
|
||||
subdominio : subdominio : mercado_admin
|
||||
```
|
||||
|
||||
**c) `.env` / `secrets`** (gitignored, fora do repo): senha do `pgdev` (`PGDEV_PASSWORD`) e as senhas de origem da VPS3. **Nunca** versionar.
|
||||
|
||||
**d) `pg-dev-refresh.sh <projeto>`** — atualiza o `_ref` a partir da VPS3 e recria o `_dev`:
|
||||
```
|
||||
1. pg_dump -Fc da VPS3 (banco_origem) usando a credencial do manifesto (read-only).
|
||||
2. DROP/CREATE <projeto>_ref no pgdev; pg_restore do dump → _ref.
|
||||
3. DROP <projeto>_dev; CREATE DATABASE <projeto>_dev TEMPLATE <projeto>_ref.
|
||||
```
|
||||
|
||||
**e) `pg-dev-reset.sh <projeto>`** — reset rápido (sem ir à VPS3):
|
||||
```
|
||||
DROP <projeto>_dev; CREATE DATABASE <projeto>_dev TEMPLATE <projeto>_ref;
|
||||
```
|
||||
|
||||
## 2.3 Apontar os backends de DEV para o pgdev (sem afetar produção)
|
||||
|
||||
Parametrizar a `DATABASE_URL` no compose de cada projeto para usar variáveis com **default = produção**, e na VPS4 criar um `.env` (gitignored) que sobrescreve só no dev:
|
||||
```
|
||||
# no docker-compose.yml (default mantém produção intacta):
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST:-10.99.0.3}:5432/${DB_NAME:-scoreodonto}
|
||||
|
||||
# .env SÓ na VPS4 (dev):
|
||||
DB_HOST=pgdev
|
||||
DB_NAME=scoreodonto_dev
|
||||
DB_USER=devadmin
|
||||
DB_PASS=<senha pgdev>
|
||||
```
|
||||
- Na VPS1 (produção) não existe esse `.env` → resolve para `10.99.0.3/scoreodonto`. **Produção nunca muda.**
|
||||
- Rebuild/recreate do backend de dev → passa a falar com `pgdev`.
|
||||
|
||||
## 2.4 Passos de implantação (quando aprovado)
|
||||
|
||||
1. Criar `/home/deploy/stack/pg-dev/` com o compose + scripts + `.env` (secrets).
|
||||
2. `docker compose up -d pgdev` (rootless) + adicionar `pgdev` à rede `soc`.
|
||||
3. Rodar `pg-dev-refresh.sh scoreodonto` (e rx, subdominio) → cria os `_ref` e `_dev`.
|
||||
4. Criar o `.env` de dev em cada projeto e recriar os backends de dev.
|
||||
5. Verificar: `dev.scoreodonto.com/api/version` sobe; conferir que escrita em dev cai em `scoreodonto_dev` (e produção em `scoreodonto` permanece intacta).
|
||||
6. (privacidade) opcional: script de anonimização de dados sensíveis nos `_dev`.
|
||||
|
||||
## 2.5 Operação do dia a dia
|
||||
|
||||
- **Quer dados atualizados da produção?** `pg-dev-refresh.sh <projeto>`.
|
||||
- **Sujou o dev e quer voltar ao limpo?** `pg-dev-reset.sh <projeto>` (segundos, via TEMPLATE).
|
||||
- **Projeto novo?** adiciona 1 linha no `pg-dev-projects.conf` + `pg-dev-refresh.sh <novo>`. Pronto — sem novo container.
|
||||
|
||||
## 2.6 Custo e ganho
|
||||
|
||||
- **Custo:** 1 container (~50–100 MB RAM, ~100 MB disco). Disco da VPS4 hoje: ~64 GB livres → folga total.
|
||||
- **Ganho:** dev deixa de poder corromper produção; resets instantâneos; PostGIS coberto; padrão único que escala para N projetos sem multiplicar containers.
|
||||
|
||||
## 2.7 O que este plano deliberadamente NÃO faz
|
||||
- Não cria 2 containers por projeto (over-engineering p/ bancos de 8–21 MB).
|
||||
- Não usa réplica streaming (exigiria config superuser na VPS3 + WAL; canhão p/ 40 MB).
|
||||
- Não toca em produção (VPS3/VPS1) — refresh é one-way, só `pg_dump`.
|
||||
|
||||
---
|
||||
|
||||
# PARTE 3 — Status de implementação (16/jun/2026)
|
||||
|
||||
**Implementado em `/home/deploy/stack/pg-dev/`** (Docker rootless da VPS4):
|
||||
- ✅ Container **`pgdev`** (`postgis/postgis:18-3.6`) na rede `soc`, sem porta pública, volume `pgdev-data` em `/var/lib/postgresql`.
|
||||
- ✅ Scripts `pg-dev-refresh.sh` / `pg-dev-reset.sh` + manifesto `pg-dev-projects.conf` + `secrets.env` (gitignored).
|
||||
- ✅ Bancos criados (ref+dev): **scoreodonto** (20 MB, PostGIS 3.6.3, 1750 pacientes), **rx** (dental_images), **subdominio**.
|
||||
|
||||
**Backends apontados (via `.env` gitignored + `DATABASE_URL`/`PG_*` parametrizados com default=produção):**
|
||||
- ✅ **ScoreOdonto** → `pgdev/scoreodonto_dev` (validado: backend conecta, migrações OK; `dev.scoreodonto.com` no ar). Exigiu ajuste de SSL no backend (não força SSL p/ host `@pgdev`).
|
||||
- ✅ **Subdomínio** → `pgdev/subdominio_dev` (env confirmado, site no ar).
|
||||
- ✅ **RX** → `pgdev/rx_dev` (`rx.scoreodonto.com` no ar). Solução: app adicionado à rede `soc` + `DB_HOST/DB_NAME` parametrizados; role `clube67` criado no pgdev com a senha do `.secrets` (assim DB_USER/senha não mudam).
|
||||
|
||||
**Produção (VPS3/VPS1) intocada.** Para um projeto novo: adicionar linha no `pg-dev-projects.conf`, `SRC_<proj>_PASS` no `secrets.env`, rodar `./pg-dev-refresh.sh <proj>` e parametrizar o compose do projeto + `.env`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Deploy & Versionamento — Runbook ScoreOdonto
|
||||
|
||||
> Auditado contra o sistema em **17/jun/2026**. Cada item carrega seu **status real**.
|
||||
>
|
||||
> **Legenda:** ✅ EM PRODUÇÃO · 🧪 EM DEV (não promovido) · 🎯 ALVO (não implementado).
|
||||
>
|
||||
> **Produção AGORA:** `v1.0.16 · commit 471c2c2 · 2026-06-17` (`curl https://scoreodonto.com/api/version`).
|
||||
> Modelo **Build Once → Promote → Deploy** completo — Passo 5 validado em v1.0.16. Tudo abaixo ✅.
|
||||
|
||||
---
|
||||
|
||||
## 1. O modelo ✅
|
||||
|
||||
> **Push** na `main` builda **uma vez** e publica por **commit**; a **tag `vX.Y.Z`** **re-tagueia** esse mesmo artefato (sem rebuild) e deploya. **Produção nunca builda — só executa imagens.**
|
||||
|
||||
```
|
||||
PUSH main (commit C) TAG vX.Y.Z (no commit C)
|
||||
│ │
|
||||
▼ ▼
|
||||
CI: build (1x) CI: promote (SEM build) ✅
|
||||
publica :C + :latest re-tag :C → :vX.Y.Z (MESMO digest) + deploy
|
||||
▼ ▼
|
||||
registry VPS1: pull :vX.Y.Z + up --no-build
|
||||
APP_VERSION=tag injetada em runtime
|
||||
```
|
||||
Prova (v1.0.16): `:v1.0.16` tem **digest idêntico** a `:471c2c2` — a tag é só um rótulo sobre a imagem já testada.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estado por componente (verificado)
|
||||
|
||||
| Componente | Status | Detalhe |
|
||||
|---|---|---|
|
||||
| Infra VPS1/3/4 + Docker rootless | ✅ | Mapa na §3 |
|
||||
| Registry (`git.clube67.com/ruicesar/...`) | ✅ | Ver `REGISTRY.md` |
|
||||
| CI: job `build` publica por **commit** em push (sem `APP_VERSION`) | ✅ | `.gitea/workflows/build.yml` (runner `vps4-builder`) |
|
||||
| Job `promote` por tag: re-tag `:<commit>→:vX.Y.Z` (sem rebuild) + deploy | ✅ | validado em v1.0.16 (`:v1.0.16` == `:471c2c2`) |
|
||||
| Validação de integridade no promote (commit existe? digest igual? front+back mesmo commit) | ✅ | falha se o commit não foi pushado |
|
||||
| Produção não builda (`image:` + `pull` + `up --no-build`) | ✅ | `docker-compose.prod.yml` |
|
||||
| Banco DEV isolado em `pgdev`; prod no Postgres da VPS3 | ✅ | Ver `BANCO-DEV-ESTRATEGIA.md` |
|
||||
| Versão em runtime (`/api/version`) → Configurações + telas públicas | ✅ | `useAppVersion()`; sem `APP_VERSION` manual |
|
||||
| `APP_VERSION`/`APP_ENV` injetados no deploy em runtime | ✅ | `compose.prod` `environment` |
|
||||
| Versão **nasce da tag git** (`constants.ts`/`update-version.js` removidos) | ✅ | Passo 5 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Mapa de máquinas ✅
|
||||
|
||||
| Onde | Host | Papel | Docker |
|
||||
|---|---|---|---|
|
||||
| VPS1 | `10.99.0.1` | **Produção** (Traefik + apps) | rootless |
|
||||
| VPS3 | `10.99.0.3` | Postgres (prod) + Gitea + registry | — |
|
||||
| VPS4 | `10.99.0.4` | **Dev + Builder** (código; runner CI; `pgdev`) | rootless |
|
||||
|
||||
- Repo: `ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git`. Project Compose: `scoreodontocom`.
|
||||
|
||||
---
|
||||
|
||||
## 4. De onde vem a versão (`/api/version`)
|
||||
|
||||
```json
|
||||
{ "name": "ScoreOdonto API", "version": "v1.0.16", "commit": "471c2c2",
|
||||
"build": "2026-06-17 10:35 UTC", "environment": "PROD" }
|
||||
```
|
||||
- **commit / build** = carimbados no **build** (imutáveis — identidade do artefato).
|
||||
- **version / environment** = **injetados no deploy em runtime** (a partir da tag). A mesma imagem é promovida sob qualquer tag, sem rebuild.
|
||||
- **Frontend** lê tudo de `/api/version` (`useAppVersion()`); não embute versão. Fonte única = backend.
|
||||
|
||||
---
|
||||
|
||||
## 5. Runbook — subir um release ✅
|
||||
|
||||
> ⚠️ **Regra de ouro:** confirme **DEV ou PRODUÇÃO**. `dev.scoreodonto.com` = VPS4 (banco `pgdev`). `scoreodonto.com` = VPS1 (real). **Nunca** `git push --force` na `main`. Push/tag exigem autorização explícita.
|
||||
|
||||
```bash
|
||||
# 1) VPS4: validar em DEV
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
node --check backend/server.js
|
||||
docker compose build scoreodonto-backend scoreodonto-frontend
|
||||
docker compose up -d --force-recreate scoreodonto-backend scoreodonto-frontend # https://dev.scoreodonto.com
|
||||
|
||||
# 2) commit + push → CI builda 1x e publica :<commit> + :latest (NÃO deploya)
|
||||
git add -A && git commit -m "feat: <descrição>"
|
||||
git push origin main
|
||||
|
||||
# 3) tag = "aprovado p/ produção" → job PROMOTE re-tagueia :<commit>→:vX.Y.Z (SEM rebuild) + deploya
|
||||
# (o número da versão é livre — não precisa casar com nenhum arquivo)
|
||||
git tag -a vX.Y.Z -m "release vX.Y.Z" && git push origin vX.Y.Z
|
||||
|
||||
# 4) verificar (sem SSH)
|
||||
curl -s https://scoreodonto.com/api/version # → vX.Y.Z · <commit> · PROD
|
||||
```
|
||||
|
||||
**Backup do banco de PRODUÇÃO antes de releases sensíveis** (via container, pois não há shell na VPS3):
|
||||
```bash
|
||||
docker run --rm -e PGPASSWORD=<senha> -v /home/deploy/backups:/b postgres:18 \
|
||||
pg_dump -h 10.99.0.3 -U scoreodonto_user -d scoreodonto -Fc -f /b/scoreodonto_$(date +%Y%m%d_%H%M).dump
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollback ✅
|
||||
|
||||
Por imagem, **sem rebuild** — na VPS1:
|
||||
```bash
|
||||
/home/deploy/stack/scoreodonto.com/deploy-prod.sh <tag-ou-commit-anterior> # pull + up --no-build
|
||||
```
|
||||
⚠️ A referência confiável é o **commit** (`:<sha>`). Tags `:vX.Y.Z` antigas (pré-Passo 5) podem ter sido republicadas com código diferente — a partir do Passo 5, `:vX.Y.Z` é sempre um alias imutável do commit. Migrações são aditivas (voltar o código é seguro). Dump em `/home/deploy/backups/` só em extremo.
|
||||
|
||||
---
|
||||
|
||||
## 7. Follow-ups (não bloqueiam) 🎯
|
||||
- `GET /api/health` (monitoramento/diagnóstico).
|
||||
- **Política de retenção** do registry (uma imagem `:<commit>` por push acumula).
|
||||
- Reconciliar o repo na VPS1 (working tree sujo, não afeta o deploy por imagem).
|
||||
|
||||
Racional do modelo em `deploy.md`; histórico em `project-cicd-tag-promove-nao-rebuilda` (memória).
|
||||
@@ -0,0 +1,42 @@
|
||||
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
|
||||
|
||||
> Resposta direta às 6 perguntas essenciais, **verificada em 17/jun/2026**. Somente fatos do estado atual.
|
||||
> **Documento principal: em caso de divergência entre docs, este prevalece.**
|
||||
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
|
||||
|
||||
---
|
||||
|
||||
### 1. Onde está o código?
|
||||
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
|
||||
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.16 · commit `471c2c2`**.
|
||||
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
|
||||
|
||||
### 2. Onde está o banco?
|
||||
- **Produção:** PostgreSQL no host da **VPS3** (`10.99.0.3:5432`), banco `scoreodonto`, user `scoreodonto_user`.
|
||||
- ✅ **DEV ISOLADO:** a VPS4 usa um container PostGIS próprio **`pgdev`** (banco `scoreodonto_dev`), **NÃO mais o banco de produção**. Dev e prod **não compartilham** mais. (Ver `BANCO-DEV-ESTRATEGIA.md`.)
|
||||
- Cache **DragonflyDB** (`10.99.0.3:6379`, índice `/1`). **NATS** (`:4222`) e **Temporal** (`:7233`).
|
||||
|
||||
### 3. Onde estão os containers?
|
||||
- **Produção (VPS1, docker rootless):** `scoreodontocom-scoreodonto-{frontend,backend}-1` rodam a **imagem do registry** (`git.clube67.com/ruicesar/scoreodonto-*:<tag>`). Produção **não builda**. Também na VPS1: `traefik` (borda TLS), rx, newwhats, subdominio, mercado.
|
||||
- **Dev (VPS4, docker rootless):** `nginx-1` (`8020→80`, alvo do Traefik para `dev.`), `frontend-1` (`:3013`), `backend-1` (`:8018`) — **buildados localmente** e apontados ao `pgdev`.
|
||||
- **Roteamento:** Traefik na VPS1 → `scoreodonto.com` (containers locais) e `dev.scoreodonto.com` → `http://10.99.0.4:8020`.
|
||||
|
||||
### 4. Quem faz deploy?
|
||||
- **CI/CD por tag (Gitea Actions).** Push na `main` → o runner `act_runner` "vps4-builder" (VPS4, rootless) builda e **publica no registry** (NÃO deploya). `git tag vX.Y.Z && git push` → CI + job `deploy` promove para a VPS1 (`deploy-prod.sh`).
|
||||
- **Produção consome imagem** (`pull` + `up -d --no-build`).
|
||||
- ✅ **Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
|
||||
|
||||
### 5. Como versiona?
|
||||
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.16 · `471c2c2` · PROD**.
|
||||
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
|
||||
- ✅ **`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
|
||||
|
||||
### 6. Como volta uma versão (rollback)?
|
||||
- **Por imagem, sem rebuild:** na VPS1, `./deploy-prod.sh <ref-anterior>` → `pull` + `up -d --no-build`.
|
||||
- ⚠️ A referência confiável é o **commit** (`:<sha>`): tags `:vX.Y.Z` podem ter sido **republicadas** com código diferente (colisão de tags pré-Passo 5 — confirmado: `:v1.0.14` do registry ≠ o que rodava). Migrações são **aditivas** (voltar o código é seguro). Dump em `/home/deploy/backups/` só em caso extremo.
|
||||
|
||||
---
|
||||
|
||||
## Veredito de uma linha
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.16**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
|
||||
+15
-4
@@ -1,14 +1,25 @@
|
||||
# Override de PRODUÇÃO (VPS1): roda IMAGENS VERSIONADAS do registry em vez de buildar.
|
||||
# A produção não builda — só consome a imagem pronta publicada pela CI (Etapa 5).
|
||||
# A produção não builda — só consome a imagem pronta publicada pela CI.
|
||||
#
|
||||
# Uso (na VPS1), via deploy-prod.sh <tag>, que faz:
|
||||
# SCOREODONTO_TAG=v1.0.13 docker compose -f docker-compose.yml -f docker-compose.prod.yml pull ...
|
||||
# Uso (na VPS1), via deploy-prod.sh <tag>, que exporta SCOREODONTO_TAG e faz:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml pull ...
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build ...
|
||||
#
|
||||
# O --no-build garante que o build: do compose base seja ignorado (usa a imagem puxada).
|
||||
#
|
||||
# VERSÃO EM RUNTIME (Passo 3): a versão semântica EXIBIDA (/api/version → footer/Configurações)
|
||||
# vem da TAG promovida, injetada via `environment` abaixo — NÃO do build. `commit` e `build`
|
||||
# continuam vindo do ENV carimbado na imagem (imutáveis, propriedade do artefato). Assim a
|
||||
# MESMA imagem pode ser promovida sob qualquer tag sem rebuild — pré-requisito do
|
||||
# "build once, promote the same artifact". O frontend não precisa de versão aqui: é estático
|
||||
# e lê tudo de /api/version (backend) em runtime.
|
||||
services:
|
||||
scoreodonto-backend:
|
||||
image: git.clube67.com/ruicesar/scoreodonto-backend:${SCOREODONTO_TAG:-latest}
|
||||
# Mescla com o environment do compose base (por chave). Sobrescreve o APP_VERSION/APP_ENV
|
||||
# que vieram embutidos na imagem pelo build, fixando a versão = tag e o ambiente = PROD.
|
||||
environment:
|
||||
- APP_VERSION=${SCOREODONTO_TAG:-dev}
|
||||
- APP_ENV=PROD
|
||||
|
||||
scoreodonto-frontend:
|
||||
image: git.clube67.com/ruicesar/scoreodonto-frontend:${SCOREODONTO_TAG:-latest}
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ services:
|
||||
- BAILEYS_ENGINE=infinite
|
||||
- PORT=8018
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://scoreodonto_user:clube67_scoreodonto_pass_9903@10.99.0.3:5432/scoreodonto?schema=public
|
||||
- DATABASE_URL=postgresql://${SCOREO_DB_USER:-scoreodonto_user}:${SCOREO_DB_PASS:-clube67_scoreodonto_pass_9903}@${SCOREO_DB_HOST:-10.99.0.3}:5432/${SCOREO_DB_NAME:-scoreodonto}?schema=public
|
||||
- DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379/1
|
||||
- NATS_URL=nats://10.99.0.3:4222
|
||||
- TEMPORAL_ADDRESS=10.99.0.3:7233
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# Checklist de Deploy para Produção — Pacote Financeiro + Salas (jun/2026)
|
||||
|
||||
> Consolida a rodada: **Financeiro V1 (Épicos 1–4)**, **V2 Comissão/Repasse**, **Glosas**, **GTO→Financeiro**, **modelo de contrato p/ convênio** e o **redesenho de Salas** (perfil Dono de Sala, sala como workspace, financeiro de locação, painel/relatório/agenda).
|
||||
> O **procedimento canônico de deploy** é o `DEPLOY-PRODUCAO.md` — este arquivo é só o checklist desta entrega.
|
||||
|
||||
## 0. Arquitetura (sem ambiente isolado)
|
||||
- **VPS4** = **desenvolvimento + builder**. O ambiente de teste é **`dev.scoreodonto.com`** (servido pela VPS4 via Traefik da VPS1) e é onde se **buildam** as imagens que vão pra produção.
|
||||
- **VPS1** = **produção** (`scoreodonto.com`) — só recebe a imagem pronta e sobe.
|
||||
- **VPS3** = **dados** (Postgres `scoreodonto`, Dragonfly, Gitea). **Dev e prod apontam para o MESMO banco `scoreodonto`.**
|
||||
- ⚠️ Consequência (confirmada): **dev e prod compartilham o banco** → as migrações desta entrega **já foram aplicadas** ao banco real durante o desenvolvimento (rodam no boot do backend de dev). Promover é, na prática, **levar o CÓDIGO** para a VPS1 — o schema já está migrado. **Não há ambiente nem banco isolado** entre dev e produção.
|
||||
|
||||
## 1. Fluxo de promoção (resumo do DEPLOY-PRODUCAO.md §2)
|
||||
1. `git push origin main` (na VPS4) — já feito (commit `3e64514`).
|
||||
2. Bump de versão (`update-version.js` → `frontend/constants.ts`).
|
||||
3. **Build da imagem Docker na VPS4** (`npm install` + `vite build` dentro do Dockerfile) — peso fora da produção.
|
||||
4. **Envia a imagem pronta para a VPS1** (registry privado pela WireGuard — recomendado — ou `docker save | ssh | docker load`).
|
||||
5. **VPS1:** `docker compose up -d` (troca o container pela imagem nova). Backend leva ~10–30s no boot (reconecta Postgres/Dragonfly + roda migrações).
|
||||
- *(Os scripts `deploy-prod-builder.sh` e `deploy-to-prod.sh` estão **DEPRECADOS** — ver seção "Fluxo ANTIGO" do `DEPLOY-PRODUCAO.md`. Não usar.)*
|
||||
|
||||
## 2. Pré-requisitos
|
||||
- [ ] **Backup do banco** VPS3 (`pg_dump scoreodonto` — custom format `-Fc`). Como dev/prod compartilham o banco, esse backup é a **única rede de segurança**.
|
||||
- [ ] Código em `origin/main` (Gitea) — ✅ `3e64514`.
|
||||
- [ ] `node --check backend/server.js` OK; build do frontend OK.
|
||||
- [ ] Validado em `dev.scoreodonto.com`.
|
||||
|
||||
## 3. Mudanças de banco (migrações idempotentes no boot — todas ADITIVAS)
|
||||
> Já aplicadas no banco (dev=prod). Listadas para auditoria/rollback. Nenhuma destrutiva.
|
||||
- **Novas tabelas:** `procedimentos_realizados`, `procedimento_realizado_foto`, `comissao_regras`, `repasses`, `glosas`.
|
||||
- **ALTER `financeiro`:** `paciente_id, profissional_id, procedimento_id, agendamento_id, contrato_id, realizado_id, origem, sem_comissao, pago_em, deleted_at, fornecedor, centro_custo, recorrente, convenio_id, valor_glosa, data_competencia, competencia`.
|
||||
- **ALTER `orcamentos`:** `financeiro_gerado`. **ALTER `contratos`:** `cobranca_automatica, valor_recorrente, periodicidade, dia_cobranca`. **ALTER `gto_builders`:** `financeiro_id`. **ALTER `sala_reservas`:** `financeiro_id`.
|
||||
- **CHECK `usuarios_role_check` / `vinculos_role_check`** ganham `donosala` (DROP+ADD no boot — superset, seguro).
|
||||
- **Índices:** troca `uq_comissao_regra` → `uq_comissao_regra2`; novos `idx_financeiro_contrato_comp`, `idx_financeiro_convenio`, `idx_glosas_*`, `idx_gto_builders_financeiro`.
|
||||
- **Seed (boot):** `cmod_odo_14` "Atendimento a Convênios / Repasse" (ON CONFLICT DO NOTHING).
|
||||
|
||||
## 4. Backend — env / infra (VPS1)
|
||||
- [ ] `DATABASE_URL` → `…@10.99.0.3/scoreodonto`. `JWT_SECRET` estável (login + URLs assinadas de fotos/comprovantes).
|
||||
- [ ] **Volume de mídia** persistente (`MEDIA_PATH`): grava em `…/procedimentos/<id>/` e `…/repasses/<id>/`.
|
||||
- [ ] **Cron** `processarVigenciasContratos` (boot + 6h): Atrasado, vigência, **renovação automática**, **cobrança recorrente** — conferir nos logs.
|
||||
|
||||
## 5. Frontend
|
||||
- [ ] Imagem rebuildada com as views novas (`ComissoesView`, `GlosasView`, `ProcedimentosProntuario`, `SalaHomeView`) e alterações (`FinanceiroView`, `ReportsView`, `OrcamentoModal`, `ContratosView`, `LancarGTO`, `SalasPlugin`, `OnboardingChat`, `Sidebar`, `App.tsx`, `PluginsView`, `pluginRegistry`, `services/backend.ts`).
|
||||
- [ ] Conferir: locação **sempre ativa** (MINHAS/ALUGAR SALAS aparecem) e **seletor de workspace** lista Clínicas × Salas.
|
||||
|
||||
## 6. Verificação pós-deploy (VPS1)
|
||||
```bash
|
||||
# versão no ar
|
||||
curl -s https://scoreodonto.com | grep -o 'V[0-9]*\.[0-9]*\.[0-9]*' | head -1
|
||||
# logs do backend (Docker ROOTLESS na VPS1) — espere "[MIGRATION] all migrations OK"
|
||||
ssh deploy@10.99.0.1 "DOCKER_HOST=unix:///run/user/1000/docker.sock docker logs scoreodontocom-scoreodonto-backend-1 --tail 40"
|
||||
```
|
||||
Smoke tests: criar **lançamento financeiro** (valida `normalizeFinanceiro` — antes nenhum salvava pela UI); **GTO → recebível**; **comissão/repasse/comprovante**; **glosa**; **contrato de convênio**; **orçamento→parcelas**; **fluxo de salas** (Dono de Sala, reserva→recebível, painel); **isolamento cross-tenant 403**.
|
||||
|
||||
## 7. Rollback
|
||||
- Migrações **só adicionam** → reverter o **código** (imagem anterior na VPS1) é seguro; colunas/tabelas extras ficam inertes. Restaurar o dump da §2 só se necessário (`pg_restore -d "<url>" --clean <arquivo>`).
|
||||
|
||||
## 8. Riscos específicos desta entrega
|
||||
- **`normalizeFinanceiro` (crítico):** corrige o bug em que `enforceUppercase` mandava `PAGO`/`PIX` violando o CHECK capitalizado — antes a tabela `financeiro` ficava vazia. Garantir que entrou na imagem.
|
||||
- **Banco único dev/prod:** dado de teste de desenvolvimento pode aparecer; os E2E desta rodada sempre limparam, mas conferir contagens de `financeiro`, `salas`, `repasses`, `glosas`.
|
||||
- **Contratos de convênio:** textos seguem padrão de mercado — recomendar revisão por advogado.
|
||||
- **Locação sempre ativa:** todos os papéis (menos superadmin) passam a ver MINHAS/ALUGAR SALAS.
|
||||
@@ -1,158 +0,0 @@
|
||||
# DEPLOY-PRODUCAO — Runbook operacional (estado de HOJE)
|
||||
|
||||
> 🛑 **LEIA PRIMEIRO.** Procedimento **real e executável hoje**, passo a passo, sem adivinhação.
|
||||
> Reflete o mecanismo atual: **a produção (VPS1) builda a si mesma** via `git pull` + `docker compose build` + `up -d`.
|
||||
> O fluxo-alvo (registry + deploy por tag, prod sem build) está em `deploy.md` e ainda não existe. A realidade da infra está em `arquitetura.md`.
|
||||
>
|
||||
> ⚠️ **Regra de ouro:** antes de qualquer build/deploy/push, confirme com o operador **DEV ou PRODUÇÃO**.
|
||||
> `dev.scoreodonto.com` = VPS4 (descartável, mas **mesmo banco** da prod). `scoreodonto.com` = VPS1 (real).
|
||||
> **Nunca** `git push --force` na `main`. **Dev e prod compartilham o banco** (VPS3) — o backup é a única rede de segurança.
|
||||
|
||||
---
|
||||
|
||||
## Mapa rápido (quem é quem)
|
||||
|
||||
| Onde | Host | Papel | Socket Docker |
|
||||
|------|------|-------|----------------|
|
||||
| VPS4 | `10.99.0.4` | dev + builder (código aqui) | `/var/run/docker.sock` (root) |
|
||||
| VPS1 | `10.99.0.1` | produção | `unix:///run/user/1000/docker.sock` (rootless) |
|
||||
| VPS3 | `10.99.0.3` | Postgres + Gitea | — |
|
||||
|
||||
- Repo: `ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git` (Gitea).
|
||||
- Diretório do repo nas duas máquinas: `/home/deploy/stack/scoreodonto.com`.
|
||||
- Project Compose: `scoreodontocom` (containers `scoreodontocom-*`).
|
||||
|
||||
---
|
||||
|
||||
## FASE 0 — Pré-voo (na VPS4)
|
||||
|
||||
```bash
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
|
||||
# 1. Sanidade do backend
|
||||
node --check backend/server.js
|
||||
|
||||
# 2. Validar a mudança em DEV (já no ar em https://dev.scoreodonto.com)
|
||||
# Conferir o bundle servido (deve mudar a cada rebuild):
|
||||
curl -s https://dev.scoreodonto.com/ | grep -oE 'index-[A-Za-z0-9_-]+\.js' | head -1
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. BACKUP DO BANCO (rede de segurança única — dev/prod compartilham o banco)
|
||||
# Rodar na VPS3. Usar as credenciais do ambiente/segredo, nunca em texto puro.
|
||||
ssh deploy@10.99.0.3 'pg_dump -Fc scoreodonto -f /home/deploy/backups/scoreodonto_$(date +%Y%m%d_%H%M).dump'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FASE 1 — Versão (na VPS4) ⚠️ passo manual
|
||||
|
||||
O bump **não é automático** (`npm run build` é só `vite build`). É preciso incrementar **à mão**:
|
||||
|
||||
```bash
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
node update-version.js # incrementa o patch em frontend/constants.ts (ex.: V1.0.12 → V1.0.13)
|
||||
grep APP_VERSION frontend/constants.ts
|
||||
```
|
||||
|
||||
> Hoje a versão **não fica visível no navegador** (está no bundle JS). Até a Etapa 3 (versionamento visível), anote a versão que está promovendo.
|
||||
|
||||
---
|
||||
|
||||
## FASE 2 — Commit & push para o Gitea (na VPS4)
|
||||
|
||||
> Só execute com autorização explícita do operador (é o repo de produção).
|
||||
|
||||
```bash
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
git add -A
|
||||
git commit -m "feat: <descrição> (bump vX.Y.Z)"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
> ❗ Push **não** dispara deploy automático hoje (não há listener/webhook ativo). A promoção é a FASE 3.
|
||||
|
||||
---
|
||||
|
||||
## FASE 3 — Promover para produção (na VPS1)
|
||||
|
||||
```bash
|
||||
ssh deploy@10.99.0.1
|
||||
export DOCKER_HOST=unix:///run/user/1000/docker.sock # docker rootless da VPS1
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
|
||||
# 1. Trazer o código aprovado
|
||||
git pull origin main
|
||||
|
||||
# 2. Carimbo de versão visível (lido pelos build args do compose) — em PROD use APP_ENV=PROD
|
||||
export APP_VERSION=$(grep -oE 'V[0-9]+\.[0-9]+\.[0-9]+' frontend/constants.ts | head -1)
|
||||
export GIT_COMMIT=$(git rev-parse --short HEAD)
|
||||
export BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M UTC')
|
||||
export APP_ENV=PROD
|
||||
|
||||
# 3. Buildar as imagens (frontend faz vite build; backend npm install)
|
||||
docker compose build scoreodonto-frontend scoreodonto-backend
|
||||
|
||||
# 4. Subir (troca os containers pelas imagens novas)
|
||||
docker compose up -d scoreodonto-frontend scoreodonto-backend nginx
|
||||
```
|
||||
|
||||
> Os 4 `export` carimbam versão/commit/data/ambiente na imagem. Sem eles, o build ainda funciona mas mostra `dev`/`unknown` no rodapé. Em **DEV** (VPS4) use `APP_ENV=DEV`.
|
||||
|
||||
O backend leva ~10–30s no boot (reconecta Postgres/Dragonfly e roda as migrações idempotentes).
|
||||
|
||||
---
|
||||
|
||||
## FASE 4 — Verificação pós-deploy (na VPS1)
|
||||
|
||||
```bash
|
||||
export DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||
|
||||
# Containers no ar
|
||||
docker ps --format '{{.Names}} | {{.Status}}' | grep score
|
||||
|
||||
# Backend: esperar a linha de migração + carimbo de versão
|
||||
docker logs scoreodontocom-scoreodonto-backend-1 --tail 40 | grep -iE 'migration|VERSION'
|
||||
# alvo: [MIGRATION] all migrations OK
|
||||
# [VERSION] ScoreOdonto API vX.Y.Z · commit ... · build ... · PROD
|
||||
|
||||
# Confirmar a versão no ar SEM acessar container (a forma definitiva):
|
||||
curl -s https://scoreodonto.com/api/version
|
||||
# {"name":"ScoreOdonto API","version":"vX.Y.Z","commit":"...","build":"...","environment":"PROD"}
|
||||
|
||||
# E visualmente: a versão aparece no rodapé do menu lateral (Sidebar).
|
||||
```
|
||||
|
||||
**Smoke tests mínimos:** abrir `https://scoreodonto.com`, logar, e exercitar o que mudou (ex.: lançamento financeiro, agenda, salas). Conferir ausência de erro 500 no `docker logs`.
|
||||
|
||||
---
|
||||
|
||||
## FASE 5 — Rollback (se algo quebrar)
|
||||
|
||||
As migrações são **aditivas** → reverter o **código** é seguro (colunas/tabelas extras ficam inertes).
|
||||
|
||||
```bash
|
||||
ssh deploy@10.99.0.1
|
||||
export DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
|
||||
git log --oneline -5 # achar o commit bom anterior
|
||||
git checkout <commit-anterior> # ou: git reset --hard <commit-anterior>
|
||||
docker compose build scoreodonto-frontend scoreodonto-backend
|
||||
docker compose up -d scoreodonto-frontend scoreodonto-backend
|
||||
```
|
||||
|
||||
> Hoje o rollback **rebuilda** a versão anterior (as imagens são `:latest`, não há tag por versão). Quando a **Etapa 6** entregar deploy por tag + registry, o rollback vira só `docker compose pull` da tag anterior — sem rebuild. Restaurar o dump do banco (FASE 0) só em caso extremo:
|
||||
> `pg_restore -d "postgresql://scoreodonto_user:<senha>@10.99.0.3:5432/scoreodonto" --clean <arquivo>.dump`
|
||||
|
||||
---
|
||||
|
||||
## Resumo em uma tela
|
||||
|
||||
```
|
||||
VPS4: validar em dev → node --check → backup do banco (VPS3)
|
||||
→ node update-version.js → commit → git push origin main (com OK do operador)
|
||||
VPS1: git pull → docker compose build → docker compose up -d
|
||||
→ verificar logs ([MIGRATION] all migrations OK) + smoke test
|
||||
Rollback: git checkout <commit-bom> → build → up -d
|
||||
```
|
||||
@@ -1,344 +0,0 @@
|
||||
# ScoreOdonto — Pendências, Tutoria e Deploy (dev)
|
||||
|
||||
> Documento de handoff. Última atualização: 2026-06-12.
|
||||
> Contexto completo da arquitetura de agenda/workspaces: ver memória do projeto
|
||||
> `project-agenda-workspace-architecture.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deploy no container de dev (IMPORTANTE)
|
||||
|
||||
**`dev.scoreodonto.com` NÃO é Vite HMR.** É servido pelo container
|
||||
`scoreodontocom-scoreodonto-frontend-1` (projeto compose `scoreodontocom`,
|
||||
de `docker-compose.yml`) rodando **nginx sobre um `vite build` estático**.
|
||||
Edições no fonte **não aparecem sozinhas** — exigem rebuild + recreate manual.
|
||||
|
||||
### Banco é de PRODUÇÃO (compartilhado)
|
||||
- Backend de dev (VPS 4) e produção (VPS 1) usam o **MESMO Postgres na VPS 3**
|
||||
(`10.99.0.3:5432/scoreodonto`). Não há banco de dev separado.
|
||||
- Migrações aditivas idempotentes rodam no startup (`runMigrations()` em `server.js`).
|
||||
- Qualquer dado criado "testando" em dev é **dado real de produção** → sempre limpar.
|
||||
- A constraint `vinculos_role_check` é DROP+ADD a cada boot. Ao deployar na prod,
|
||||
o `server.js` de lá PRECISA incluir `'protetico'`, senão o ADD falha se já houver
|
||||
vínculo protético criado via dev.
|
||||
|
||||
### Comandos de deploy
|
||||
```bash
|
||||
cd /home/deploy/stack/scoreodonto.com
|
||||
# Frontend (server.js NÃO é hot-mount; só plugins/sessions/media são):
|
||||
docker compose build scoreodonto-frontend && docker compose up -d --force-recreate scoreodonto-frontend
|
||||
# Backend:
|
||||
docker compose build scoreodonto-backend && docker compose up -d --force-recreate scoreodonto-backend
|
||||
```
|
||||
- Verificar: o hash do bundle em `https://dev.scoreodonto.com/` (`/assets/index-*.js`) muda.
|
||||
- `vite build` NÃO faz type-check bloqueante → erros TS não quebram o build, mas
|
||||
podem estourar em runtime. Conferir após deploy.
|
||||
- Logs de migração: `docker logs --tail 20 scoreodontocom-scoreodonto-backend-1 | grep -i migration`
|
||||
(esperado: `[MIGRATION] all migrations OK`).
|
||||
|
||||
### Efeito colateral relevante
|
||||
Quando esta sessão começou, o dev servia um **build antigo** (`index-DWdouzmk.js`).
|
||||
Os rebuilds passaram a publicar o **working tree atual**, que inclui vários arquivos
|
||||
**untracked** (WIP de outra sessão): `OrthoView`, `OrthoReports`, `OrthoPhotoWorkspace`,
|
||||
`TutoriaSection`, `SuperAdminView`, `GestaoEquipeView`, `Tutor*View`, `plugins/`, etc.
|
||||
→ Bugs nesses arquivos só passaram a **aparecer agora** por causa do deploy, não por edição.
|
||||
|
||||
---
|
||||
|
||||
## 2. TUTORIA no modal de tratamento de orto — ✅ CAUSA RAIZ RESOLVIDA
|
||||
|
||||
**Relato do usuário:** "ferrou com a tutoria no modal que administra o tratamento de orto."
|
||||
|
||||
**CAUSA RAIZ (confirmada):** as telas WIP (`TutoriaSection`, `OrthoPhotoWorkspace`,
|
||||
`PhotoAnnotator`, `TutorPainelView`, `TutorCandidaturaView`, `AdminGestaoTutoresView`)
|
||||
chamavam **24 métodos do `HybridBackend` que não existiam** em `services/backend.ts`
|
||||
(só `getCurrentUserName` existia) → crash `HybridBackend.X is not a function` assim que
|
||||
o rebuild publicou esses componentes. Além disso o **backend não tinha nenhuma tabela
|
||||
nem endpoint** de orto/tutoria.
|
||||
|
||||
**CORRIGIDO nesta sessão:**
|
||||
- `services/backend.ts`: adicionados os 24 métodos via helper `_ortoJson` (chama o
|
||||
endpoint real e **degrada com fallback seguro** se a rota faltar → fim do crash).
|
||||
- `backend/server.js`: **9 tabelas** (`ortho_photo_session`, `ortho_photo`,
|
||||
`ortho_photo_annotation`, `ortho_tutor_config`, `ortho_tutor_candidatura`,
|
||||
`ortho_tutor_profile`, `ortho_tutoring_request`, `ortho_tutoring_message`,
|
||||
`ortho_tutoring_photo_package`) + **25 endpoints** + upload real de fotos via `multer`
|
||||
no volume persistente `/app/media`, servidas por `/api/orto/fotos/:id/raw`.
|
||||
- `backend/package.json`: + `multer`.
|
||||
- Validado: migrations OK, 9 tabelas no banco, endpoints respondendo (200/401/404).
|
||||
Frontend e backend rebuildados → live no dev.
|
||||
|
||||
**FEITO depois:**
|
||||
- [x] `OrthoDetailPanel` agora renderiza `OrthoPhotoWorkspace` + `TutoriaSection`
|
||||
(no lugar do `PhotoCompare` placeholder) — a tutoria/fotos vivem no modal do paciente.
|
||||
- [x] **Sessões mensais**: orto é mensal → `ortho_photo_session` ganhou `rotulo`+`data_ref`;
|
||||
o workspace cria sessão por mês (input `month`) + especiais (INICIAL/FINALIZAÇÃO/
|
||||
PÓS-CONTENÇÃO); tabs dinâmicas ordenadas. Validado end-to-end.
|
||||
- [x] **Exportar documentação**: botão EXPORTAR → modal seleciona "completa" (tudo) ou
|
||||
fotos específicas (checkbox por foto, agrupado por sessão) → abre view de impressão
|
||||
(navegador salva como PDF). Sem dependência nova.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **Tratamentos de orto persistem**: tabelas `ortho_tratamento` + `ortho_evolucao`
|
||||
(triagem/diagnóstico/flags em JSONB); endpoints GET/POST/PUT + `/evolucoes` +
|
||||
`/finalizar`. `backend.ts` troca o mock t1/t2 por chamadas reais (mappers snake↔camel).
|
||||
Botão "NOVO TRATAMENTO" abre modal de criação (paciente/plano/diagnóstico/triagem).
|
||||
Adicionar evolução atualiza `fio_atual`/`ultima_consulta`; finalizar grava contenção.
|
||||
Validado end-to-end.
|
||||
- [x] Comparador cross-sessão (mesmo slot, sessão A × B / mês a mês) com modos
|
||||
**lado a lado / slider / sobreposição**.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **MercadoPago real**: `POST /tutoria/candidaturas/:id/pagamento` cria uma
|
||||
preference (Checkout Pro) com a `taxa_inscricao` da config, `external_reference=cand:<id>`,
|
||||
`back_urls` e `notification_url` → devolve `init_point`. `POST /tutoria/mp-webhook`
|
||||
(público) consulta o pagamento e, se `approved`, ativa o tutor automaticamente
|
||||
(`ativarCandidaturaTutor`, reaproveitado pelo confirmar-pagamento do admin).
|
||||
Sem `mp_access_token` na config → fallback honesto (ativação manual). Admin configura
|
||||
token/taxa em `AdminGestaoTutoresView`. **Caveat:** não testado contra a API real do MP
|
||||
(sem credenciais); fallback e webhook-noop validados.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **GestaoEquipeView corrigida** (estava roteada em `/gestao-equipe` e quebrava igual a
|
||||
tutoria — chamava 12 métodos inexistentes). Backend: tabelas `setores`/`funcoes` +
|
||||
colunas `vinculos.setor_id/funcao_id`; endpoints membros (GET com nomes, POST com
|
||||
setor/função, **+PUT**, DELETE) e setores/funções CRUD (todos `tenantGuard`).
|
||||
`backend.ts`: os 12 métodos. Validado end-to-end + isolamento de tenant (403).
|
||||
- [x] **PDF nativo** (`jspdf` adicionado às deps): o EXPORTAR agora gera um PDF real
|
||||
(fotos agrupadas por sessão, 3/linha, legenda do slot) via canvas→jsPDF; botão
|
||||
IMPRIMIR mantém o print→PDF como alternativa. **Caveat:** geração é client-side
|
||||
(jsPDF+canvas), validei só install/tsc/build, não a renderização no navegador.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **Planejamento visual** no `PhotoAnnotator`: grupo PLANEJ. com **vetor de
|
||||
movimentação** (haste + barra de origem + cabeça larga, arrastar da posição atual →
|
||||
destino), **marca de extração** (X, clique no dente) e **linha média** (referência
|
||||
vertical tracejada, clique). Persistem como anotações (JSONB) — sem mudança no backend.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **Type-gaps zerados — `tsc` do frontend agora passa com 0 erros.** Bug real
|
||||
corrigido em `FinanceiroView` (`getPacientes` retorna `{data,total}` → usava o objeto
|
||||
inteiro como lista, quebrando a busca → agora `results.data`); `TutorPainelView` ganhou
|
||||
os campos financeiros na interface `Solicitacao`. Login/RxConfig limparam junto.
|
||||
|
||||
**FEITO depois (cont.):**
|
||||
- [x] **Calibração de escala (mm)** no `PhotoAnnotator`: ferramenta ESCALA (arrastar sobre
|
||||
distância conhecida → informa o valor real em mm) define mm/px; os **vetores de
|
||||
movimentação passam a exibir a magnitude em mm**. Rótulos renderizados fora do SVG
|
||||
(HTML posicionado por `imgDim`) p/ não distorcer com o viewBox. Persiste no JSONB.
|
||||
- [x] `RxConfigView` usava props inexistentes do `PageHeader` (`subtitle`/`icon`) → trocado
|
||||
por `description` (subtítulo agora aparece). **Resta 1 erro tsc pré-existente** em
|
||||
`LoginView:490` (comparação morta no fluxo de cadastro/auth) — deixado de propósito:
|
||||
é lógica de auth e mascarar esconderia a intenção do autor; funciona em runtime.
|
||||
- [x] Comparador ganhou modo **EVOLUÇÃO**: mesmo slot em todas as sessões/meses lado a
|
||||
lado (faixa horizontal cronológica) — além de lado a lado / slider / sobreposição.
|
||||
- [x] Vínculo com o cadastro de pacientes: o modal de Novo Tratamento tem busca no
|
||||
cadastro (`getPacientes`) que preenche `paciente_id` + nome + nascimento; digitar
|
||||
livre cria paciente avulso (sem vínculo). Frontend-only, validado por tsc/build.
|
||||
|
||||
---
|
||||
|
||||
## 2b. Apuração original (histórico)
|
||||
|
||||
**Apuração (fatos):**
|
||||
- Não foi editado nenhum arquivo de orto/tutoria nesta sessão. Todos estão **untracked**
|
||||
(`git status` → `??`): `OrthoView.tsx`, `OrthoReports.tsx`, `TutoriaSection.tsx`,
|
||||
`TutorPainelView.tsx`, `TutorCandidaturaView.tsx`, `AdminGestaoTutoresView.tsx`.
|
||||
- `OrthoView.tsx` **não tem nenhuma referência a "tutor"**.
|
||||
- `TutoriaSection.tsx` **não é importada em lugar nenhum** (código solto/WIP).
|
||||
- Quem referencia tutor: **`OrthoReports.tsx`** (e os Tutor*View).
|
||||
- Único toque indireto possível: `services/backend.ts` (eu alterei `getCurrentRole`,
|
||||
mas só com fallback p/ superadmin quando não há workspace — não afeta dentista/tutoria;
|
||||
`getCurrentUserName`, usado pela TutoriaSection, NÃO foi alterado).
|
||||
- Provável causa: o rebuild publicou a versão WIP da tutoria que antes não estava no ar.
|
||||
|
||||
**PENDENTE — preciso do sintoma para diagnosticar:**
|
||||
- [ ] Onde exatamente? (OrthoView do paciente / OrthoReports / "Meus Tratamentos" / outro)
|
||||
- [ ] O que acontece? (crash/tela branca / botão sumiu / dado não carrega / erro console)
|
||||
- [ ] Mensagem de erro no console do navegador?
|
||||
|
||||
→ Com o sintoma, achar a causa raiz em `OrthoReports.tsx` / `TutoriaSection.tsx` e corrigir.
|
||||
|
||||
---
|
||||
|
||||
## 3. Status das fases (arquitetura agenda/workspaces)
|
||||
|
||||
- [x] **Fase 0** — Cadastro: 5 cards de perfil com preço (Clínica R$499 separada). `LoginView.tsx`.
|
||||
- [x] **Fase 1** — Workspace pessoal automático no `/api/register` (dentista/protético/paciente);
|
||||
`clinicas.tipo`+`owner_id`; `protetico` adicionado ao `vinculos_role_check`. Fim do beco "aguardando-convite".
|
||||
- [x] **Fase 2** — Identidade global: `dentistas.usuario_id` + backfill por email; criação seta usuario_id.
|
||||
- [x] **Fase 3** — Anti-overbooking global: `POST /api/agendamentos` resolve identidade do
|
||||
profissional e bloqueia sobreposição em QUALQUER clínica → 409 `{conflito, ocupado, podeRegistrarInteresse}`.
|
||||
- [~] **Fase 4** — Fila de interesse:
|
||||
- [x] Backend: tabela `interesses_agenda`, `POST/GET /api/interesses`, hook no DELETE de
|
||||
agendamento que notifica interessados como `enviado_por='sistema'` e marca `liberado`.
|
||||
- [x] Frontend Parte 1: AgendaView mostra painel "Horário ocupado" no 409 → registrar interesse (com minutos de espera).
|
||||
- [x] Frontend Parte 2: dentista vê "Pedidos de interesse" (sino + badge + modal; GET retorna `clinica_nome`).
|
||||
- [x] `POST /api/clinicas` passou a setar `owner_id`+`tipo`.
|
||||
- [ ] **FALTA**: endpoint **"tentar liberar"** (clínica pede → dentista decide mover/cancelar o ocupante).
|
||||
- [ ] **FALTA**: **re-notificação periódica** do sistema enquanto o dentista demora (job/cron),
|
||||
sempre rotulada como "notificação do sistema" (impessoal).
|
||||
- [ ] **Fase 5** — Odontodelivery: diretório (`disponivel_diretorio` já existe) → pedido de horário ao freelancer.
|
||||
|
||||
### Regras de negócio confirmadas
|
||||
- Profissional não pode estar em 2 lugares ao mesmo tempo (invariante: 1 agendamento por slot).
|
||||
- Conflito não é "não" seco: vira fila de INTERESSE (não trava o slot). Clínica pode: registrar
|
||||
interesse com tempo de espera (expira), escolher outro horário, ou pedir pro dentista liberar.
|
||||
- Todos têm workspace (pessoal automático no cadastro).
|
||||
|
||||
---
|
||||
|
||||
## 4. Bugs pré-existentes encontrados (não introduzidos nesta sessão)
|
||||
|
||||
- [x] **`notificacoes.lida` é smallint** (confirmado no banco: `int2`), mas havia código
|
||||
usando boolean → corrigido (2026-06-12). Locais: `POST /api/superadmin/notificacoes`
|
||||
inseria `false` (server.js:4385 e 4393) e o marcar-como-lida usava `true/false`
|
||||
(`POST /api/notificacoes/read-all` server.js:1584 e `.../:id/read` server.js:1601).
|
||||
Todos trocados para `1/0`. Os demais INSERT (270, 2188) já usavam `0`.
|
||||
- [x] ~~`POST /api/agendamentos` default `status='Agendado'` viola `agendamentos_status_check`~~
|
||||
**NÃO procede mais.** A constraint atual é case-insensitive e aceita `agendado`
|
||||
(`lower(status) IN ('importado','agendado','confirmado','reagendado','remarcar',
|
||||
'cancelado','falta','atendido','pendente','concluido','faltou')`). O default do código
|
||||
é `rest.status || 'agendado'` → válido. Há registros `agendado` em uso no banco.
|
||||
- [x] ~~`PUT /api/agendamentos/:id` não passa pelo check de overlap~~ **NÃO procede.**
|
||||
O PUT chama `findOverbookingConflict(...)` e retorna 409 em conflito (server.js:~2013).
|
||||
|
||||
---
|
||||
|
||||
## 4c. Integração Plugin RX ↔ servidor dental (2026-06-12) — MODELO GLOBAL
|
||||
|
||||
**Modelo correto (confirmado pelo usuário):** config é **GLOBAL, só do superadmin**
|
||||
(não por clínica, não por usuário admin da conta). Hierarquia desejada no RX:
|
||||
`scoreodonto / conta-mestre (consulttclinic@gmail.com) / clinicaID / paciente`.
|
||||
Mapa: conta-mestre = `api_token` global · clinicaID = `images.clinic_name` ·
|
||||
paciente = `images.patient_name`.
|
||||
|
||||
**Menu — feito antes:** `Sidebar.tsx` voltou a mostrar **PLUGINS** ao superadmin
|
||||
(allowlist linha 67 estava sem `'plugins'`).
|
||||
|
||||
**FASE 0 — scoreodonto realinhado p/ GLOBAL (feito, deploy dev):**
|
||||
- [x] `settings.rx_config_global` = `{ rx_url, api_token }`. **Sem login/senha, sem cache**:
|
||||
o backend usa o `api_token` estático direto como Bearer (`_rxAdminFetch`). `api_tokens`
|
||||
do RX são aceitos pelo `authenticateToken` (viram admin, **não expiram**).
|
||||
- [x] `PUT /api/rx/config` agora é **`superadminGuard`** (só superadmin grava); token preservado
|
||||
se vier vazio. `GET /api/rx/config` (authGuard) devolve `{rx_url, has_token, configured}` (sem token).
|
||||
- [x] `POST /api/rx/devices` cria no RX com **`clinic_name = clinicaId`** (e-mail default único
|
||||
por clínica+PC: `<clinicaId>.<pcSlug>@rx.scoreodonto.com`). `GET /api/rx/devices?clinicaId=`
|
||||
filtra por clinic_name.
|
||||
- [x] **Revertido** o que era do modelo errado: bypass do `tenantGuard` (removido) e seletor de
|
||||
clínica do `RxConfigView` (removido).
|
||||
- [x] `RxConfigView`: card **CONFIGURAÇÃO GERAL DO RX** (URL + token) editável **só superadmin**;
|
||||
clínica vê status read-only + cadastra seus PCs. Superadmin vê PCs de **todas** as clínicas.
|
||||
- [x] `backend.ts`: `getRxConfig()` (0 args, `{rx_url,has_token,configured}`), `putRxConfig({rx_url,api_token})`,
|
||||
`createRxDevice` sem clinicName. `RXPlugin.tsx`: `getRxConfig()` sem arg.
|
||||
|
||||
**FASE 1 — servidor RX (dev.rx, `dental-server/routes/images.js`) (feito, restart dev):**
|
||||
- [x] Filtro **`?clinic=<clinicaId>`** (= `clinic_name`) em `GET /api/images`, `/images/patients`
|
||||
e `/images/by-patient`. Novo `GET /api/images/unique-clinics` (clinic_name + contagens).
|
||||
- [x] Validado: `node --check` ok; `rx_dev_api` reiniciado (porta 3000); endpoints → 401 (auth ok).
|
||||
⚠️ `npm run dev` = `node server.js` (SEM nodemon) → mudanças no RX exigem `docker restart rx_dev_api`.
|
||||
|
||||
**Validado geral:** tsc 0 erros; scoreodonto backend+frontend rebuildados no dev (bundle `govSqkSI`).
|
||||
|
||||
**FASE 2/3 — leitura por proxy (feito, deploy dev bundle `BVGGJsCu`):**
|
||||
- [x] Backend: `GET /api/rx/patients?clinicaId=&search=` e `GET /api/rx/images?clinicaId=&paciente=`
|
||||
→ proxy p/ RX `/images/patients` e `/images/by-patient` com `clinic=<clinicaId>` + api_token.
|
||||
Isolamento `_rxClinicScopeOk`: superadmin vê tudo; demais só clínicas com vínculo (senão 403).
|
||||
- [x] `backend.ts`: `getRxPatients`, `getRxPatientImages`. `RXPlugin` consome esses (lista de
|
||||
pacientes + imagens do paciente). Thumbnails seguem públicos (`/api/images/:id/thumbnail`).
|
||||
|
||||
**PENDENTE:**
|
||||
- [ ] **Falta o `api_token`** colado em CONFIGURAÇÃO GERAL DO RX (superadmin) → sem ele, criar PC
|
||||
dá 400 e a leitura volta vazia. Gerar via `POST /api/auth/tokens` do RX (usuário `is_admin`).
|
||||
- [ ] **Imagem em tamanho cheio** (`/images/:id/file-url`) ainda é autenticada → falta proxy
|
||||
`GET /api/rx/image/:id/file` (galeria mostra thumbnail público; full-res é follow-up).
|
||||
- [ ] **GTOs / upload / trigger-sync** do RXPlugin ainda usam `rxFetch` direto (token vazio) —
|
||||
já estavam quebrados antes; migrar p/ proxy se forem usados.
|
||||
- [ ] **Ativação do plugin** ainda é localStorage + toggle só superadmin (PluginsView) → rever
|
||||
p/ ativação por clínica no backend.
|
||||
- [ ] **Promover FASES 1–3 p/ prod** (rx.scoreodonto.com + scoreodonto prod) após validar no dev.
|
||||
|
||||
---
|
||||
|
||||
## 4d. Plugins: Locação de Salas + Marketplace de Profissionais (2026-06-12) — deploy dev
|
||||
|
||||
Dois plugins novos no padrão do RX (catálogo `PluginsView` + `pluginRegistry` localStorage
|
||||
+ `pluginMenuItems` no Sidebar). Bundle `index-BylSJWKo.js`; `tsc` 0 erros; migrations OK.
|
||||
|
||||
**`locacao-salas`** (teal, view `/salas`, `views/plugins/SalasPlugin.tsx`):
|
||||
- Tabelas `salas` + `sala_reservas`. CRUD do locador, marketplace (tipo/cidade/UF),
|
||||
valores por hora/turno/diária/semanal/mensal.
|
||||
- Reserva em transação com anti-conflito TRIPLO → 409: sala ocupada, reserva do próprio
|
||||
solicitante em outra sala, e **agenda global** (`findOverbookingConflict` via
|
||||
`dentistas.usuario_id`) — sala entra na invariante "1 lugar por vez".
|
||||
- Fluxo: pendente → confirmar/recusar (locador) / cancelar (ambos). Notificações
|
||||
(`notificarUsuario`) + `audit_log` (`entidade='sala'|'sala_reserva'`).
|
||||
|
||||
**`profissionais-marketplace`** (laranja, view `/marketplace-profissionais`,
|
||||
`views/plugins/ProfissionaisPlugin.tsx`) — decisão: **Delivery (nível 8) virou "Profissionais"**:
|
||||
- `usuarios` + `cep`, `raio_atuacao_km`, `bio_profissional` (raio é informativo, sem geodistância).
|
||||
- `GET /api/profissionais/marketplace` inclui **biomédico** (o diretório antigo não incluía)
|
||||
e filtra por CEP-prefixo. `GET/PUT /api/profissionais/perfil` (opt-in `disponivel_diretorio`).
|
||||
- Tabela `propostas_profissional`: clínica→profissional (`POST /api/propostas` com `tenantGuard`,
|
||||
anti-duplicata pendente), aceitar/recusar (profissional) / cancelar (clínica) + notificações.
|
||||
- **Consolidado com o Diretório antigo** (bundle `DZJWcMDu`): com o plugin ativo, o item
|
||||
"DIRETÓRIO PROF." some do menu e a rota `/diretorio-profissionais` renderiza o plugin;
|
||||
com o plugin inativo, o `DiretorioProfissionaisView` antigo continua intacto (sem regressão).
|
||||
`DiretorioProfissionaisView`/`/api/profissionais-disponiveis` podem ser removidos quando
|
||||
o plugin virar padrão.
|
||||
|
||||
**Como testar:** superadmin → PLUGINS → ativar (lembrete: ativação é **localStorage por
|
||||
navegador** — pendência 4c vale aqui também) → itens aparecem no menu dos papéis operacionais.
|
||||
Propostas exigem workspace ativo (clínica/consultório); aba MEU PERFIL só para
|
||||
dentista/biomedico/protetico.
|
||||
|
||||
**⚠️ LIMPEZA pós-teste (banco é de PRODUÇÃO):** dados de teste ficam em `salas`,
|
||||
`sala_reservas`, `propostas_profissional`, colunas novas de `usuarios` e respingos em
|
||||
`notificacoes`/`audit_log`. Limpar com:
|
||||
```sql
|
||||
DELETE FROM sala_reservas; DELETE FROM salas; DELETE FROM propostas_profissional;
|
||||
-- e reverter perfis de teste:
|
||||
-- UPDATE usuarios SET disponivel_diretorio=false, raio_atuacao_km=NULL, bio_profissional=NULL WHERE id='...';
|
||||
```
|
||||
|
||||
**Fluxos institucionais de locação** (2026-06-13, bundle `CvP3Q2X_`):
|
||||
- `sala_reservas` + `clinica_id`/`clinica_nome`/`profissional_usuario_id`/`profissional_nome`.
|
||||
- **Clínica aluga sala**: ReservaModal ganhou "RESERVAR COMO: PESSOAL | <unidade ativa>";
|
||||
como unidade, pode designar o profissional (`GET /api/dentistas?clinicaId=`, só os com
|
||||
`usuario_id`). O **anti-conflito roda sobre o OCUPANTE** (designado ou solicitante) —
|
||||
`COALESCE(profissional_usuario_id, solicitante_id)` nas reservas + agenda global.
|
||||
Designado é notificado. Membros da unidade veem/cancelam as reservas dela
|
||||
(`GET /api/sala-reservas/minhas?clinicaId=`, vínculo validado).
|
||||
- **Consultório/clínica anuncia a própria sala**: checkbox "ANUNCIAR PELA UNIDADE: <nome>"
|
||||
no cadastro/edição (grava `salas.clinica_id`; PUT aceita desvincular). Marketplace e
|
||||
cards mostram "POR <unidade>" (join `clinicas`).
|
||||
- **Dentista aluga sala**: fluxo pessoal original, inalterado.
|
||||
|
||||
**Alinhado com `/superadmin/financeiro`** (bundle `CXf4eiwZ`): a Tabela de Preços ganhou a
|
||||
seção **PLUGINS (ADD-ONS)** — chaves `plugin:locacao-salas` e `plugin:profissionais-marketplace`
|
||||
no mesmo `settings.pricing` (0 = incluído no plano). `GET /api/superadmin/pricing` agora mescla
|
||||
defaults (chaves novas aparecem em configs antigas) e ganhou a linha **biomédico** que faltava.
|
||||
Os cards da Área de Plugins exibem o preço configurado (chip "R$ X/MÊS" / "INCLUÍDO NO PLANO").
|
||||
A COBRANÇA efetiva do add-on (bloquear plugin sem pagamento) segue o modelo manual atual de
|
||||
pagamentos por usuário — não implementada.
|
||||
|
||||
**PENDENTE (roadmap acordado):**
|
||||
- [ ] Cobrança efetiva dos add-ons (ativação por clínica no backend + verificação de pagamento —
|
||||
junto com a pendência 4c de tirar a ativação do localStorage).
|
||||
- [ ] Score Professional (motor de pontuação — alimentado por reservas/propostas/faltas).
|
||||
- [ ] Geodistância real (CEP→lat/lng; raio passa a filtrar de verdade).
|
||||
- [ ] Fotos das salas (upload via `/app/media`, como as fotos de orto).
|
||||
- [ ] Pagamento da locação (MercadoPago já existe na tutoria — reaproveitar).
|
||||
- [ ] Proposta aceita → criar `vinculos` automaticamente (hoje o aceite é só status+notificação).
|
||||
|
||||
---
|
||||
|
||||
## 5. Estado de versionamento
|
||||
|
||||
- **NADA foi commitado** nesta sessão. Tudo está no working tree, rodando contra o banco de prod.
|
||||
- Regra do projeto: **commit/push só com autorização explícita** — o push dispara o pipeline de
|
||||
produção via webhook do Gitea (pode sobrescrever trabalho em andamento).
|
||||
- Arquivos que ESTA sessão editou: `backend/server.js`, `frontend/App.tsx`,
|
||||
`frontend/components/Sidebar.tsx`, `frontend/services/backend.ts`, `frontend/views/AgendaView.tsx`,
|
||||
`frontend/views/LoginView.tsx`, `frontend/views/LandingPage.tsx`.
|
||||
(Demais arquivos modificados/untracked são WIP de sessões anteriores.)
|
||||
@@ -1,46 +0,0 @@
|
||||
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
|
||||
|
||||
> Resposta direta às 6 perguntas essenciais, **verificada em 16/jun/2026**. Sem teoria, sem fantasma.
|
||||
> Detalhes em `arquitetura.md` (real), `deploy.md` (alvo) e `DEPLOY-PRODUCAO.md` (runbook).
|
||||
|
||||
---
|
||||
|
||||
### 1. Onde está o código?
|
||||
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com` (`ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git`).
|
||||
- **Cópia de trabalho (dev):** VPS4 em `/home/deploy/stack/scoreodonto.com` (onde se programa).
|
||||
- **Cópia de produção:** VPS1 em `/home/deploy/stack/scoreodonto.com` (HEAD verificado: `19dbbae`).
|
||||
- **Documentação:** centralizada em `scoreodonto.com/documentação/` (este diretório).
|
||||
|
||||
### 2. Onde está o banco?
|
||||
- **PostgreSQL** no host da **VPS3** (`10.99.0.3:5432`), banco `scoreodonto`, usuário `scoreodonto_user`.
|
||||
- ⚠️ **DEV e PROD usam o MESMO banco.** Não há isolamento. Backup (`pg_dump -Fc`) é a única rede de segurança.
|
||||
- Cache **DragonflyDB** (`10.99.0.3:6379`, índice `/1`). Mensageria **NATS** (`:4222`) e **Temporal** (`:7233`).
|
||||
|
||||
### 3. Onde estão os containers?
|
||||
- **Produção (VPS1, docker rootless `unix:///run/user/1000/docker.sock`):**
|
||||
- `scoreodontocom-scoreodonto-frontend-1` — Up ~5 dias (imagem `:latest` buildada na própria VPS1).
|
||||
- `scoreodontocom-scoreodonto-backend-1` — Up ~5 dias (`:8018`).
|
||||
- `traefik-traefik-1` — borda TLS (Up ~13 dias). Também: rx, newwhats, subdominio, mercado.
|
||||
- **Dev (VPS4, docker root `/var/run/docker.sock`):**
|
||||
- `scoreodontocom-nginx-1` (`0.0.0.0:8020->80`, alvo do Traefik para `dev.`), `scoreodonto-frontend-1` (`:3013`), `scoreodonto-backend-1` (`:8018`).
|
||||
- **Roteamento:** Traefik na VPS1 → `scoreodonto.com` (containers locais) e `dev.scoreodonto.com` → `http://10.99.0.4:8020`.
|
||||
|
||||
### 4. Quem faz deploy?
|
||||
- **Hoje: ninguém automático.** Não há listener nem webhook ativo (o antigo `listener-scoreodonto.js` porta 9002 foi deletado; serviço desligado). **`git push` NÃO dispara deploy.**
|
||||
- O deploy é **manual**: na VPS1, `git pull` + `docker compose build` + `docker compose up -d` (passo a passo em `DEPLOY-PRODUCAO.md`).
|
||||
- Ou seja: **a produção builda a si mesma** hoje (o oposto do alvo "prod não builda", que depende do registry — Etapas 4–6).
|
||||
|
||||
### 5. Como versiona?
|
||||
- `APP_VERSION` em `frontend/constants.ts` (dev hoje **`V1.0.13`**), incrementado por `update-version.js` (patch++).
|
||||
- ⚠️ **Bump é manual** — nada chama `update-version.js` no build (`npm run build` = só `vite build`). Por isso a versão fica parada se ninguém rodar o script.
|
||||
- ✅ **Versão agora é visível** (Etapa 3, 16/jun): rodapé do Sidebar + endpoint `GET /api/version` + log de boot `[VERSION] …`. Carimbo (commit/data/ambiente) injetado no build via build args. **No ar em DEV; produção passa a exibir após o próximo deploy.**
|
||||
|
||||
### 6. Como volta uma versão (rollback)?
|
||||
- **Hoje:** na VPS1, `git checkout <commit-bom>` + `docker compose build` + `up -d` (rebuilda a versão anterior; imagens são `:latest`, sem tag por versão).
|
||||
- Seguro porque as migrações são **aditivas** (voltar código não quebra o schema). Restaurar dump só em caso extremo.
|
||||
- **Alvo (Etapa 6):** rollback = `docker compose pull` da tag anterior no registry, **sem rebuild**.
|
||||
|
||||
---
|
||||
|
||||
## Veredito de uma linha
|
||||
> O código está salvo no Gitea (VPS3) e roda em containers na VPS1 (prod) e VPS4 (dev), ambos no **mesmo banco** da VPS3. **Não há CI/CD automático**: deploy e rollback são manuais via `git pull` + `docker compose build/up` na VPS1, e a **versão é invisível e bumpada à mão** — as duas dores que as Etapas 3–6 vão eliminar.
|
||||
@@ -1,131 +0,0 @@
|
||||
# Arquitetura ScoreOdonto — Estado REAL
|
||||
|
||||
> Documento de **estado atual verificado** (16/jun/2026). Descreve apenas o que existe e está rodando hoje.
|
||||
> Sem referências históricas, sem staging, sem listeners desativados. Para o fluxo-alvo de deploy ver `deploy.md`; para o passo a passo operacional ver `DEPLOY-PRODUCAO.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Visão geral
|
||||
|
||||
O ScoreOdonto roda sobre **3 VPS** ligadas por uma rede privada **WireGuard** (`10.99.0.0/24`):
|
||||
|
||||
| VPS | IP interno | Papel |
|
||||
|------|-------------|-------------------------------|
|
||||
| VPS1 | 10.99.0.1 | **Produção** (borda + apps) |
|
||||
| VPS3 | 10.99.0.3 | **Dados & Controle** (Postgres, Gitea) |
|
||||
| VPS4 | 10.99.0.4 | **Desenvolvimento & Builder** |
|
||||
|
||||
```
|
||||
Internet (HTTPS)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────── VPS1 (10.99.0.1) ─────────────────────────────┐
|
||||
│ Traefik (TLS Let's Encrypt, entrypoint websecure) │
|
||||
│ • scoreodonto.com / www ─────► containers de PRODUÇÃO (local) │
|
||||
│ • dev.scoreodonto.com ─────► http://10.99.0.4:8020 (via WireGuard) │
|
||||
│ │
|
||||
│ Produção (imagens buildadas): │
|
||||
│ scoreodontocom-scoreodonto-frontend-1 (SPA em nginx) │
|
||||
│ scoreodontocom-scoreodonto-backend-1 (Node :8018) │
|
||||
│ (também hospeda: rx, newwhats, subdominio, mercado) │
|
||||
└────────────────────────────────┬──────────────────────────────────────────┘
|
||||
│ WireGuard
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼
|
||||
┌──── VPS3 (10.99.0.3) ────┐ ┌──── VPS4 (10.99.0.4) ──────────┐
|
||||
│ PostgreSQL :5432 │◄────────────│ Desenvolvimento + Builder │
|
||||
│ db: scoreodonto │ (dev E │ nginx (borda dev) :8020→80 │
|
||||
│ Dragonfly :6379 (/1) │ prod │ └─► frontend :3013 │
|
||||
│ NATS :4222 │ usam o │ └─► backend (/api):8018 │
|
||||
│ Temporal :7233 │ MESMO │ Código-fonte em: │
|
||||
│ Gitea :3000 │ banco) │ /home/deploy/stack/ │
|
||||
│ repo: ruicesar/ │ │ scoreodonto.com/ │
|
||||
│ scoreodonto.com │ │ Docker: socket ROOT │
|
||||
│ Registry: NÃO existe │ │ (/var/run/docker.sock) │
|
||||
│ (porta 5000 fechada) │ │ │
|
||||
└───────────────────────────┘ └─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. VPS1 — Produção (10.99.0.1)
|
||||
|
||||
- **Traefik** (container `traefik-traefik-1`) é a borda: termina TLS (Let's Encrypt, resolver `le`, entrypoint `websecure`) e roteia por `Host`.
|
||||
- `scoreodonto.com` e `www.scoreodonto.com` → containers de produção locais.
|
||||
- `dev.scoreodonto.com` → `http://10.99.0.4:8020` (encaminha para a VPS4 pela WireGuard). Config dinâmica em `traefik/dynamic/dev.scoreodonto.yml`.
|
||||
- **Containers de produção** (imagens já buildadas, project `scoreodontocom`):
|
||||
- `scoreodontocom-scoreodonto-frontend-1` — SPA React servido por nginx.
|
||||
- `scoreodontocom-scoreodonto-backend-1` — API Node/Express na porta `8018`.
|
||||
- **Docker rootless**: o daemon roda no socket do usuário `deploy` (`unix:///run/user/1000/docker.sock`). Comandos `docker` na VPS1 precisam dessa variável `DOCKER_HOST`.
|
||||
- Produção **não builda** o ScoreOdonto: ela sobe imagens prontas. (O `docker-compose.yml` tem `build:`, mas o fluxo correto é trazer a imagem pronta da VPS4 — ver `deploy.md`.)
|
||||
|
||||
## 3. VPS3 — Dados & Controle (10.99.0.3)
|
||||
|
||||
Serviços instalados **direto no host** (não em container):
|
||||
|
||||
- **PostgreSQL** `:5432` — banco `scoreodonto`, usuário `scoreodonto_user`. **Fonte de dados única.**
|
||||
- **DragonflyDB** `:6379` — cache (índice `/1` reservado ao ScoreOdonto).
|
||||
- **NATS** `:4222` e **Temporal** `:7233` — mensageria / workflows.
|
||||
- **Gitea** `:3000` — **fonte oficial da verdade do código**. Repo `ruicesar/scoreodonto.com`, acesso por `ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git`. É o motor de CI/CD (ver `deploy.md`).
|
||||
- **Registry de imagens:** o **Container Registry embutido do Gitea** está ativo e em uso, acessível via HTTPS em `git.clube67.com` (namespace `ruicesar/`). Não há `registry:2` separado. Ver `REGISTRY.md`.
|
||||
|
||||
## 4. VPS4 — Desenvolvimento & Builder (10.99.0.4)
|
||||
|
||||
- **Esta é a máquina de trabalho.** Código-fonte em `/home/deploy/stack/scoreodonto.com/`.
|
||||
- **Docker é ROOT** aqui (socket `/var/run/docker.sock`) — diferente da VPS1.
|
||||
- Containers em execução (project `scoreodontocom`):
|
||||
- `scoreodontocom-nginx-1` — borda do dev, publica `0.0.0.0:8020->80`. É o alvo do Traefik para `dev.scoreodonto.com`.
|
||||
- `scoreodontocom-scoreodonto-frontend-1` — SPA (porta interna `3013`).
|
||||
- `scoreodontocom-scoreodonto-backend-1` — API Node (porta interna `8018`).
|
||||
- **O dev NÃO é Vite HMR.** O container de frontend roda uma **imagem buildada** (`vite build` → `nginx:alpine`); editar `frontend/` ou `backend/` exige **rebuild + recreate** do container para refletir em `dev.scoreodonto.com`. (Existe um override `docker-compose.dev.yml` com Vite/HMR, mas não é o que está no ar.)
|
||||
- O `nginx/default.conf` da VPS4 faz: `/api/` e `/socket.io/` → `scoreodonto-backend:8018`; `/` → `scoreodonto-frontend:3013`.
|
||||
|
||||
### Caminho de uma requisição
|
||||
|
||||
- **Produção:** navegador → `https://scoreodonto.com` → Traefik (VPS1, TLS) → frontend/backend de produção (VPS1) → Postgres (VPS3).
|
||||
- **Dev:** navegador → `https://dev.scoreodonto.com` → Traefik (VPS1, TLS) → `10.99.0.4:8020` (nginx VPS4) → frontend/backend de dev (VPS4) → **mesmo Postgres** (VPS3).
|
||||
|
||||
---
|
||||
|
||||
## 5. ⚠️ Banco compartilhado entre DEV e PROD
|
||||
|
||||
**Dev e produção apontam para o MESMO banco `scoreodonto` na VPS3.** O backend de dev usa `DATABASE_URL=postgresql://scoreodonto_user:<senha>@10.99.0.3:5432/scoreodonto`.
|
||||
|
||||
Consequências práticas:
|
||||
- Migrações rodadas no boot do backend de dev **já alteram o banco real**. As migrações do projeto são **idempotentes e aditivas** (só criam tabelas/colunas), o que torna isso tolerável — mas **não há isolamento**.
|
||||
- A **única rede de segurança** é o backup (`pg_dump scoreodonto -Fc`) antes de mudanças sensíveis.
|
||||
- Isolar um banco `scoreodonto_dev` é o conserto estrutural recomendado para o futuro (fora do escopo deste documento).
|
||||
|
||||
---
|
||||
|
||||
## 6. Stack técnica e portas
|
||||
|
||||
| Componente | Tecnologia | Porta interna |
|
||||
|------------|-----------|----------------|
|
||||
| Frontend | React + Vite + TypeScript + Tailwind (build estático servido por nginx) | 3013 |
|
||||
| Backend | Node.js + Express + driver `pg` nativo (**sem ORM**) | 8018 |
|
||||
| Borda dev (VPS4) | nginx:alpine | 8020 → 80 |
|
||||
| Banco | PostgreSQL 18 | 5432 |
|
||||
| Cache | DragonflyDB (índice `/1`) | 6379 |
|
||||
| Mensageria | NATS / Temporal | 4222 / 7233 |
|
||||
| Git/CI | Gitea | 3000 |
|
||||
| Borda prod | Traefik (TLS Let's Encrypt) | 443 (websecure) |
|
||||
|
||||
**Imagens (Dockerfiles):**
|
||||
- Frontend: multi-stage — `node:20-alpine` (build `npm run build`) → `nginx:alpine` (serve `dist/`).
|
||||
- Backend: `node:20-slim`, `npm install --omit=dev`, `CMD node server.js`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Versionamento (estado atual e limitação)
|
||||
|
||||
- A versão vive em `frontend/constants.ts` (`APP_VERSION`, hoje `V1.0.12`) e é incrementada (patch) por `update-version.js` antes do build.
|
||||
- **Limitação crítica:** a versão **não aparece** no HTML servido — fica embutida no bundle `index-<hash>.js`. Hoje só dá para saber a versão no ar inspecionando o container. É por isso que não se consegue confirmar "subiu mesmo?" pelo navegador. **Tornar a versão visível** (rodapé + endpoint + log de boot) é uma melhoria planejada (ver lista de afazeres / `deploy.md`).
|
||||
|
||||
---
|
||||
|
||||
## 8. O que NÃO existe (para encerrar fantasmas)
|
||||
|
||||
- ❌ **Não existe staging.** Não há `staging.scoreodonto.com`, nem stack `*-staging`, nem banco `scoreodonto_staging`. O único ambiente de teste é `dev.scoreodonto.com` (VPS4), com o banco de produção.
|
||||
- ❌ **Não existe listener de deploy ativo** para o scoreodonto.com. O antigo `listener-scoreodonto.js` (porta 9002) foi deletado e o serviço está desligado; não há webhook do scoreodonto.com registrado no Gitea. Hoje, `git push` **não dispara** deploy automático. (O fluxo-alvo para reativar isso está em `deploy.md`.)
|
||||
- ✅ **Registry:** existe e está em uso — é o Container Registry embutido do Gitea (`git.clube67.com`), não um serviço separado. Ver `REGISTRY.md`.
|
||||
@@ -1,103 +0,0 @@
|
||||
# Deploy ScoreOdonto — Como um commit vira produção (estado-ALVO)
|
||||
|
||||
> Este documento responde **uma** pergunta: **"como um commit vira produção?"**
|
||||
> Descreve o **fluxo-alvo** com **Gitea como motor de CI/CD**. O que já existe hoje está marcado como ✅; o que ainda será construído está marcado como 🔜.
|
||||
> Para a realidade da infra ver `arquitetura.md`. Para o procedimento manual executável **hoje** ver `DEPLOY-PRODUCAO.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios (inegociáveis)
|
||||
|
||||
1. **Gitea (VPS3) é a fonte única da verdade** e o motor de CI/CD.
|
||||
2. **Produção (VPS1) nunca builda e nunca recebe código-fonte** — só consome **imagens versionadas** prontas.
|
||||
3. **Build acontece fora da produção** (na CI / na VPS4).
|
||||
4. **Deploy é um ato deliberado por TAG**, nunca automático em todo push (a `main` é usada para desenvolvimento e o banco é compartilhado — promover sem querer é perigoso).
|
||||
5. **Rollback é trocar a imagem** por uma tag anterior — sem rebuild, sem recompilar, sem copiar arquivo.
|
||||
6. **Toda imagem é rastreável**: carrega versão semântica + commit + data de build.
|
||||
|
||||
---
|
||||
|
||||
## 2. O fluxo-alvo, em uma frase
|
||||
|
||||
> **Push** abastece o desenvolvimento e (🔜) **produz uma imagem versionada no registry**;
|
||||
> **criar uma tag `vX.Y.Z`** é o que **promove** essa imagem para produção.
|
||||
|
||||
```
|
||||
git push ──► Gitea (VPS3) ──► CI: build da imagem ──► Registry privado (VPS3)
|
||||
(na main) (versão+commit+data) imagem :vX.Y.Z
|
||||
│
|
||||
criar tag vX.Y.Z ────┘
|
||||
▼
|
||||
VPS1: docker compose pull + up -d
|
||||
(troca o container pela imagem da tag)
|
||||
▼
|
||||
Produção rodando vX.Y.Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. As duas metades do fluxo
|
||||
|
||||
### Metade A — Integração contínua (push → imagem) ✅ FEITO
|
||||
1. Desenvolvedor trabalha na VPS4 e valida em `https://dev.scoreodonto.com`.
|
||||
2. `git push origin main` (ou tag `v*`) para o Gitea (VPS3).
|
||||
3. A **CI do Gitea** (Gitea Actions) builda backend+frontend via `docker compose build`, injetando **versão, commit curto e data de build** (build args → `VITE_*`/`process.env`).
|
||||
4. As imagens são enviadas ao **Container Registry do Gitea** com tags `:<versão>`, `:<commit-curto>` e `:latest`.
|
||||
5. **Nada vai para produção aqui.** Esta metade só produz e guarda o artefato.
|
||||
|
||||
> **Implementado em 16/jun/2026.** Workflow: `.gitea/workflows/build.yml`. Runner: `vps4-builder` (act_runner 0.6.1, **modo host** com Docker, serviço systemd `act_runner` na VPS4, label `self-hosted`). Login no registry usa o secret `REGISTRY_TOKEN` se presente, senão a credencial do host. Validado: push na main → build → `scoreodonto-{backend,frontend}:v1.0.13`/`:<commit>`/`:latest` publicados.
|
||||
|
||||
### Metade B — Entrega por tag (tag → produção) ✅ FEITO
|
||||
1. Quando uma versão está aprovada, cria-se uma **tag semântica**: `git tag v1.0.15 && git push origin v1.0.15`.
|
||||
2. A CI builda e publica a imagem `:v1.0.15` no registry **e** o job `deploy` (só em tags `v*`) promove para a VPS1.
|
||||
3. Na VPS1, o `deploy-prod.sh <tag>` faz `docker compose pull` + `up -d --no-build` apontando para a imagem da tag.
|
||||
4. A versão visível (rodapé/endpoint `/api/version`) confirma a tag no ar com `environment: PROD`.
|
||||
|
||||
> **Implementado em 16/jun/2026.** `docker-compose.prod.yml` (imagens do registry, sem build) + `deploy-prod.sh <tag>` (na VPS1) + job `deploy` no workflow (SSH VPS4→VPS1, só em tags). **Push na main NÃO deploya — só tags.** Rollback: `deploy-prod.sh <tag-anterior>`. Validado: v1.0.13 promovida e confirmada (`V1.0.13 · fc0d65f · PROD`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Versionamento visível (pré-requisito da confiança) ✅ FEITO
|
||||
|
||||
> Implementado em 16/jun/2026 (Etapa 3). O build carimba versão/commit/data/ambiente via build args (`GIT_COMMIT`, `BUILD_TIME`, `APP_ENV`, `APP_VERSION`) → `import.meta.env.VITE_*` no frontend e `process.env` no backend. Visível no **rodapé do Sidebar**, no endpoint **`GET /api/version`** e na **linha de boot** `[VERSION] …` dos logs.
|
||||
|
||||
Cada build carimba e **exibe**:
|
||||
|
||||
```
|
||||
ScoreOdonto
|
||||
v1.0.15
|
||||
commit: 7a9b3c1
|
||||
build: 2026-06-16 14:32 UTC
|
||||
ambiente: PRODUÇÃO
|
||||
```
|
||||
|
||||
Visível no **rodapé** da aplicação, em um **endpoint de diagnóstico** (ex.: `GET /api/version`) e na **linha de boot** dos logs. Hoje a versão fica escondida no bundle `index-<hash>.js` (ver `arquitetura.md` §7) — esta é a primeira melhoria de maior valor.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback 🔜
|
||||
|
||||
- Listar as tags/imagens no registry da VPS3.
|
||||
- Na VPS1, apontar o compose para a tag anterior (`:v1.0.14`) e `docker compose up -d`.
|
||||
- Recuperação em minutos, **sem rebuild**. O banco é aditivo (ver `arquitetura.md` §5), então voltar o código é seguro; restaurar dump só em caso extremo.
|
||||
|
||||
---
|
||||
|
||||
## 6. Estado de hoje (honesto) ✅/❌
|
||||
|
||||
- ✅ `git push` para o Gitea **funciona** (guarda o código).
|
||||
- ❌ **Não há build automático**, **não há registry**, **não há deploy automático**. O antigo listener (porta 9002) foi deletado e não há webhook do scoreodonto.com no Gitea.
|
||||
- ➡️ Enquanto a CI acima não existe, a promoção para produção é **manual** e está documentada passo a passo em **`DEPLOY-PRODUCAO.md`** (build na VPS4 → enviar imagem para a VPS1 → `up -d`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Roadmap para sair do "hoje" e chegar no "alvo"
|
||||
|
||||
| Etapa | Entrega | Status |
|
||||
|-------|---------|--------|
|
||||
| 3 | Versionamento visível (rodapé + endpoint + log) | ✅ |
|
||||
| 4 | Registry Docker privado na VPS3 | ✅ (Gitea Container Registry — ver `REGISTRY.md`) |
|
||||
| 5 | Pipeline de build no Gitea (push → imagem → registry), **sem deploy** | ✅ (`.gitea/workflows/build.yml` + runner `vps4-builder`) |
|
||||
| 6 | Deploy por TAG (`vX.Y.Z` → produção na VPS1) + rollback por imagem | ✅ (`deploy-prod.sh` + job `deploy` em tags) |
|
||||
|
||||
Quando as Etapas 3–6 estiverem prontas, este documento deixa de ter marcações 🔜 e passa a descrever o fluxo corrente.
|
||||
+14
-3
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
import { useAppVersion } from './buildInfo.ts';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
import { LeadsView } from './views/LeadsView.tsx';
|
||||
@@ -47,6 +48,7 @@ import { AdminGestaoTutoresView } from './views/AdminGestaoTutoresView.tsx';
|
||||
import { WaitingInviteView } from './views/WaitingInviteView.tsx';
|
||||
import { SuperAdminView } from './views/SuperAdminView.tsx';
|
||||
import { DiretorioProfissionaisView } from './views/DiretorioProfissionaisView.tsx';
|
||||
import { ProteseView } from './views/ProteseView.tsx';
|
||||
|
||||
export type ViewKey =
|
||||
| 'landing'
|
||||
@@ -59,6 +61,7 @@ export type ViewKey =
|
||||
| 'financeiro'
|
||||
| 'comissoes'
|
||||
| 'glosas'
|
||||
| 'proteses'
|
||||
| 'login'
|
||||
| 'update'
|
||||
| 'dentistas'
|
||||
@@ -147,6 +150,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/superadmin/notificacoes': 'superadmin-notificacoes',
|
||||
'/aguardando-convite': 'aguardando-convite',
|
||||
'/diretorio-profissionais': 'diretorio-profissionais',
|
||||
'/proteses': 'proteses',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
@@ -198,6 +202,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'superadmin-financeiro': '/superadmin/financeiro',
|
||||
'superadmin-notificacoes': '/superadmin/notificacoes',
|
||||
'diretorio-profissionais': '/diretorio-profissionais',
|
||||
proteses: '/proteses',
|
||||
};
|
||||
|
||||
/** Resolve current URL to a ViewKey */
|
||||
@@ -261,6 +266,7 @@ const MobileBottomNav: React.FC<{
|
||||
|
||||
// ------- App -------
|
||||
const App: React.FC = () => {
|
||||
const ver = useAppVersion();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
@@ -293,10 +299,10 @@ const App: React.FC = () => {
|
||||
|
||||
const permissions: Record<string, ViewKey[]> = {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
return permissions[role]?.includes(view) || false;
|
||||
@@ -455,6 +461,7 @@ const App: React.FC = () => {
|
||||
case 'relatorios': return <ReportsView />;
|
||||
case 'comissoes': return <ComissoesView />;
|
||||
case 'glosas': return <GlosasView />;
|
||||
case 'proteses': return <ProteseView />;
|
||||
case 'cadastro-dentista': return <DentistRegisterView />;
|
||||
case 'configuracoes': return <ConfiguracoesView />;
|
||||
case 'orcamentos': return <OrcamentosView />;
|
||||
@@ -572,6 +579,10 @@ const App: React.FC = () => {
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
{/* Versão sempre visível no canto inferior direito (lê /api/version; não bloqueia cliques) */}
|
||||
<div className="fixed bottom-1.5 right-2 z-40 pointer-events-none select-none text-[10px] font-mono text-gray-400/70">
|
||||
{ver.version}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+24
-6
@@ -1,17 +1,35 @@
|
||||
import { APP_VERSION } from './constants.ts';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Metadados de build. commit/build/ambiente são carimbados no build via
|
||||
// variáveis VITE_* (ARGs do Dockerfile do frontend). Em dev sem build args,
|
||||
// caem nos defaults abaixo.
|
||||
// Carimbo do BUILD (bundle). É só FALLBACK enquanto o /api/version não responde.
|
||||
// A FONTE OFICIAL da versão é o backend, em runtime, via useAppVersion().
|
||||
// Não depende mais de APP_VERSION manual: version cai em VITE_APP_VERSION (se o
|
||||
// pipeline injetar) ou 'dev'. commit/build/ambiente seguem carimbados no build (VITE_*).
|
||||
const meta = (import.meta as any).env || {};
|
||||
|
||||
export const BUILD_INFO = {
|
||||
name: 'ScoreOdonto',
|
||||
version: APP_VERSION,
|
||||
version: meta.VITE_APP_VERSION || 'dev',
|
||||
commit: meta.VITE_GIT_COMMIT || 'dev',
|
||||
build: meta.VITE_BUILD_TIME || '',
|
||||
environment: String(meta.VITE_APP_ENV || 'DEV').toUpperCase(),
|
||||
};
|
||||
|
||||
// Rótulo curto: "V1.0.13 · 7a9b3c1 · DEV"
|
||||
// Rótulo curto: "v1.0.15 · 7a9b3c1 · DEV"
|
||||
export const versionLabel = `${BUILD_INFO.version} · ${BUILD_INFO.commit} · ${BUILD_INFO.environment}`;
|
||||
|
||||
// Fonte ÚNICA da versão exibida: o backend em runtime (GET /api/version, público).
|
||||
// Cache em módulo p/ não refazer o fetch a cada tela. Fallback = BUILD_INFO (bundle).
|
||||
let _verCache: any = null;
|
||||
export function useAppVersion() {
|
||||
const [info, setInfo] = useState<any>(_verCache || BUILD_INFO);
|
||||
useEffect(() => {
|
||||
if (_verCache) { setInfo(_verCache); return; }
|
||||
let alive = true;
|
||||
fetch('/api/version')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(v => { if (alive && v && v.version) { _verCache = { ...BUILD_INFO, ...v }; setInfo(_verCache); } })
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const NotificationCenter: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-[1002]">
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
@@ -75,8 +75,8 @@ export const NotificationCenter: React.FC = () => {
|
||||
|
||||
{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="fixed inset-0 z-40" onClick={toggleOpen} />
|
||||
<div className="absolute right-0 mt-4 w-[380px] z-50 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>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { ToothIcon } from './ToothIcon.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
|
||||
import { BUILD_INFO } from '../buildInfo.ts';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
@@ -63,6 +62,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
|
||||
{ id: 'proteses', label: 'PRÓTESES', icon: FlaskConical },
|
||||
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
|
||||
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
|
||||
@@ -123,13 +123,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
if (currentRole === 'donosala') return ['notificacoes', 'configuracoes'].includes(item.id);
|
||||
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
// Dentista: clínico odonto completo (ortodontia, tutoria orto).
|
||||
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
|
||||
// Biomédico(a): seu próprio catálogo (especialidades/procedimentos de biomedicina);
|
||||
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
|
||||
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
|
||||
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -284,15 +284,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
<LogOut size={16} />
|
||||
SAIR DO SISTEMA
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="px-4 pt-1 text-[8px] leading-tight text-gray-300 font-mono select-text"
|
||||
title={`Build: ${BUILD_INFO.build || 'n/a'}`}
|
||||
>
|
||||
<span className="font-bold text-gray-400">{BUILD_INFO.version}</span>
|
||||
{' · '}{BUILD_INFO.commit}
|
||||
{' · '}<span className={BUILD_INFO.environment === 'PRODUÇÃO' || BUILD_INFO.environment === 'PROD' ? 'text-emerald-400' : 'text-amber-400'}>{BUILD_INFO.environment}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const APP_VERSION = 'V1.0.14';
|
||||
@@ -742,6 +742,79 @@ export const HybridBackend = {
|
||||
await apiFetch(`${API_URL}/glosas/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// ── Próteses · Ordem de Serviço (clínica ↔ laboratório/protético) ───────────
|
||||
getProteseLaboratorios: async () => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const qs = new URLSearchParams({ clinicaId });
|
||||
if (opts.status) qs.set('status', opts.status);
|
||||
if (opts.paciente_id) qs.set('paciente_id', opts.paciente_id);
|
||||
const res = await apiFetch(`${API_URL}/protese/os?${qs.toString()}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseBancada: async (status?: string) => {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
const res = await apiFetch(`${API_URL}/protese/bancada${qs}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseOSDetalhe: async (id: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}`);
|
||||
return await res.json();
|
||||
},
|
||||
createProteseOS: async (payload: any) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/os?clinicaId=${encodeURIComponent(clinicaId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar OS');
|
||||
return await res.json();
|
||||
},
|
||||
setProteseOSStatus: async (id: string, status: string, observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
uploadProteseFoto: async (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseOS: async (id: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
},
|
||||
// Catálogo do protético (preço fixo + garantia)
|
||||
getProteseProdutos: async () => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos`);
|
||||
return await res.json();
|
||||
},
|
||||
getLaboratorioProdutos: async (proteticoId: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/produtos?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
createProteseProduto: async (payload: { nome: string; preco?: number; garantia_meses?: number }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar produto');
|
||||
return await res.json();
|
||||
},
|
||||
updateProteseProduto: async (id: string, payload: any) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar produto');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseProduto: async (id: string) => {
|
||||
await apiFetch(`${API_URL}/protese/produtos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// ── GTO (builder → finaliza → lança no financeiro) ──────────────────────────
|
||||
createGtoBuilder: async (payload: { pacienteId: string; dentistaId: string; credenciadaId: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/gto-builder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { ChangePasswordModal } from '../components/ChangePasswordModal.tsx';
|
||||
import { APP_VERSION } from '../constants.ts';
|
||||
import { useAppVersion } from '../buildInfo.ts';
|
||||
import { buscarCep } from '../services/viacep.ts';
|
||||
|
||||
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition-all';
|
||||
@@ -352,6 +352,8 @@ export const ConfiguracoesView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
// Versão/commit/ambiente vêm do backend em RUNTIME (fonte única). Fallback: carimbo do bundle.
|
||||
const ver = useAppVersion();
|
||||
|
||||
const authHeader = { 'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`, 'Content-Type': 'application/json' };
|
||||
|
||||
@@ -417,7 +419,7 @@ export const ConfiguracoesView: React.FC = () => {
|
||||
<p className="text-sm text-gray-400 font-medium mt-1">Gerencie seu perfil e integrações</p>
|
||||
</div>
|
||||
<span className="bg-gray-100 text-gray-400 text-[11px] font-mono font-bold px-3 py-1 rounded-full border border-gray-200 mb-1">
|
||||
{APP_VERSION}
|
||||
{ver.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -567,6 +569,29 @@ export const ConfiguracoesView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sobre o sistema — versão/commit/ambiente vindos do backend em runtime */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50">
|
||||
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest">Sobre o sistema</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<dl className="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-sm font-mono">
|
||||
<dt className="text-gray-400 font-bold uppercase text-[11px] tracking-widest self-center">Versão</dt>
|
||||
<dd className="text-gray-900 font-bold">{ver.version}</dd>
|
||||
<dt className="text-gray-400 font-bold uppercase text-[11px] tracking-widest self-center">Commit</dt>
|
||||
<dd className="text-gray-700">{ver.commit}</dd>
|
||||
<dt className="text-gray-400 font-bold uppercase text-[11px] tracking-widest self-center">Build</dt>
|
||||
<dd className="text-gray-700">{ver.build || '—'}</dd>
|
||||
<dt className="text-gray-400 font-bold uppercase text-[11px] tracking-widest self-center">Ambiente</dt>
|
||||
<dd>
|
||||
<span className={`font-black ${ver.environment === 'PRODUÇÃO' || ver.environment === 'PROD' ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{ver.environment}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showChangePw && <ChangePasswordModal onClose={() => setShowChangePw(false)} onDone={() => setShowChangePw(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
UserSearch, DoorOpen, ScanLine, Sparkles, MapPin, Building2, Users, FlaskConical
|
||||
} from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { APP_VERSION } from '../constants.ts';
|
||||
import { useAppVersion } from '../buildInfo.ts';
|
||||
import { OnboardingChat } from './OnboardingChat.tsx';
|
||||
|
||||
interface LoginViewProps {
|
||||
@@ -57,6 +57,7 @@ const InputCls = 'w-full pl-10 pr-4 py-3.5 bg-gray-50 border border-gray-200 rou
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const ver = useAppVersion(); // versão oficial via /api/version (público), fallback bundle
|
||||
const [tab, setTab] = useState<'login' | 'cadastro'>('login');
|
||||
const [authMode, setAuthMode] = useState<'login' | 'forgot' | 'reset'>('login');
|
||||
|
||||
@@ -215,7 +216,7 @@ export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 flex items-center gap-2 text-teal-300/50 text-[10px] font-black uppercase tracking-widest">
|
||||
<ShieldCheck size={12} /> Dados criptografados · LGPD · {APP_VERSION}
|
||||
<ShieldCheck size={12} /> Dados criptografados · LGPD · {ver.version}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -335,7 +336,7 @@ export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
</div>
|
||||
<div className="px-8 py-4 bg-gray-50 border-t border-gray-100 flex items-center justify-center gap-2">
|
||||
<ShieldCheck size={12} className="text-gray-400" />
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Ambiente Seguro · {APP_VERSION}</span>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Ambiente Seguro · {ver.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
|
||||
prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado',
|
||||
};
|
||||
const STATUS_COR: Record<string, string> = {
|
||||
solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700',
|
||||
prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700',
|
||||
entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600',
|
||||
};
|
||||
const LAB_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
|
||||
const Badge: React.FC<{ s: string }> = ({ s }) => (
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
|
||||
);
|
||||
|
||||
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
|
||||
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
const [paciente, setPaciente] = useState<any>(null);
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [form, setForm] = useState<any>({ protetico_id: '', produto_id: '', tipo: '', dentista_id: '', material: '', cor: '', dentes: '', prazo_entrega: '', valor: '', observacoes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [ws?.id]);
|
||||
|
||||
// Catálogo (preço fixo) do laboratório escolhido.
|
||||
useEffect(() => {
|
||||
if (!form.protetico_id) { setProdutos([]); return; }
|
||||
HybridBackend.getLaboratorioProdutos(form.protetico_id)
|
||||
.then(r => setProdutos(Array.isArray(r) ? r : []))
|
||||
.catch(() => setProdutos([]));
|
||||
setForm((f: any) => ({ ...f, produto_id: '', tipo: '' }));
|
||||
}, [form.protetico_id]);
|
||||
|
||||
const produtoSel = produtos.find(p => p.id === form.produto_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (paciente) return;
|
||||
const t = setTimeout(() => {
|
||||
HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([]));
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [busca, paciente]);
|
||||
|
||||
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
||||
|
||||
const salvar = async () => {
|
||||
if (!form.protetico_id) { toast.error('SELECIONE O LABORATÓRIO.'); return; }
|
||||
if (!form.produto_id && !form.tipo) { toast.error('SELECIONE O PRODUTO.'); return; }
|
||||
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||
setSaving(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
...form,
|
||||
valor: Number(form.valor) || 0,
|
||||
paciente_id: paciente?.id || null,
|
||||
paciente_nome: paciente?.nome || null,
|
||||
dentista_nome: dent?.nome || null,
|
||||
});
|
||||
toast.success('OS DE PRÓTESE CRIADA.');
|
||||
onSaved();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> Nova OS de Prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Paciente */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
||||
{paciente ? (
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1">
|
||||
<span className="text-sm font-bold text-gray-700">{paciente.nome}</span>
|
||||
<button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mt-1">
|
||||
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
||||
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..."
|
||||
className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
{busca && pacientes.length > 0 && (
|
||||
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
||||
{pacientes.slice(0, 8).map(p => (
|
||||
<button key={p.id} onClick={() => { setPaciente(p); }} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Laboratório */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
|
||||
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}</option>)}
|
||||
</select>
|
||||
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
|
||||
</div>
|
||||
{/* Produto (catálogo do laboratório — preço fixo) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
|
||||
{form.protetico_id && produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => set('produto_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(p.preco)}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
|
||||
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
|
||||
)}
|
||||
{produtoSel && (
|
||||
<p className="text-[10px] text-gray-500 mt-1">Custo de laboratório <b>{fmtBRL(produtoSel.preco)}</b> · garantia <b>{produtoSel.garantia_meses} meses</b></p>
|
||||
)}
|
||||
</div>
|
||||
{/* Dentista */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">—</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{/* Material + cor + dentes */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Material</label><input value={form.material} onChange={e => set('material', e.target.value)} placeholder="Zircônia..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Cor</label><input value={form.cor} onChange={e => set('cor', e.target.value)} placeholder="A2..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Dentes</label><input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="11,21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
</div>
|
||||
{/* Prazo + valor cobrado do paciente */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Prazo de entrega</label><input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Valor ao paciente R$</label><input type="number" value={form.valor} onChange={e => set('valor', e.target.value)} placeholder="0,00" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Observações</label>
|
||||
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 border-t border-gray-100 flex gap-3 sticky bottom-0 bg-white">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-gray-200 font-black text-gray-500 text-sm uppercase">Cancelar</button>
|
||||
<button onClick={salvar} disabled={saving} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-sm uppercase disabled:opacity-50">{saving ? 'Salvando...' : 'Criar OS'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: Detalhe da OS (timeline + fotos + ações por papel) ───────────────
|
||||
const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose: () => void; onChanged: () => void }> = ({ id, modo, onClose, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
||||
}, [id, toast]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const mudar = async (status: string) => {
|
||||
if (status === 'cancelado' && !(await confirm('CANCELAR ESTA OS?'))) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.setProteseOSStatus(id, status); toast.success(`OS: ${(STATUS_LABEL[status] || status).toUpperCase()}.`); carregar(); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(id, file); toast.success('FOTO ANEXADA.'); carregar(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acionarGarantia = async () => {
|
||||
if (!(await confirm('ABRIR UMA OS DE GARANTIA (SEM CUSTO) PARA REFAZER ESTE TRABALHO?'))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
protetico_id: os.protetico_id, produto_id: os.produto_id || null, tipo: os.tipo,
|
||||
tipo_os: 'garantia', origem_os_id: os.id,
|
||||
paciente_id: os.paciente_id, paciente_nome: os.paciente_nome,
|
||||
dentista_id: os.dentista_id, dentista_nome: os.dentista_nome,
|
||||
material: os.material, cor: os.cor, dentes: os.dentes,
|
||||
observacoes: `Garantia da OS ${os.id}`,
|
||||
});
|
||||
toast.success('OS DE GARANTIA ABERTA (SEM CUSTO).');
|
||||
onChanged(); onClose();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!os) return null;
|
||||
const finalizado = os.status === 'entregue' || os.status === 'cancelado';
|
||||
const naGarantia = os.status === 'entregue' && os.garantia_ate && os.garantia_ate >= new Date().toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<div>
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge s={os.status} />
|
||||
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
|
||||
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> {dataBR(os.prazo_entrega)}</span>}
|
||||
</div>
|
||||
{os.garantia_ate && (
|
||||
<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold">
|
||||
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
|
||||
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
|
||||
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor</span>{fmtBRL(os.valor)}</div>}
|
||||
</div>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
|
||||
|
||||
{/* Fotos */}
|
||||
{os.fotos?.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{os.fotos.map((f: any) => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-100" /></a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-400 mt-1 shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-gray-700">{STATUS_LABEL[ev.status] || ev.observacao || ev.status}</span>
|
||||
<span className="text-gray-400"> · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações por papel */}
|
||||
{!finalizado && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
|
||||
{modo === 'bancada' ? (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LAB_FLUXO.map(s => (
|
||||
<button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)}
|
||||
className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer">
|
||||
<ImagePlus size={15} /> Anexar foto do trabalho
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto antes de entregar (garantia)</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
|
||||
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white">
|
||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ───────
|
||||
const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [itens, setItens] = useState<any[]>([]);
|
||||
const [novo, setNovo] = useState({ nome: '', preco: '', garantia_meses: '12' });
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState<any>({});
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseProdutos().then(r => setItens(Array.isArray(r) ? r : [])).catch(() => setItens([]));
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const add = async () => {
|
||||
if (!novo.nome.trim()) { toast.error('INFORME O NOME.'); return; }
|
||||
try { await HybridBackend.createProteseProduto({ nome: novo.nome.trim(), preco: Number(novo.preco) || 0, garantia_meses: Number(novo.garantia_meses) || 12 });
|
||||
setNovo({ nome: '', preco: '', garantia_meses: '12' }); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
const salvarEdit = async (id: string) => {
|
||||
try { await HybridBackend.updateProteseProduto(id, { nome: edit.nome, preco: Number(edit.preco) || 0, garantia_meses: Number(edit.garantia_meses) || 12 }); setEditId(null); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
const remover = async (id: string) => {
|
||||
if (!(await confirm('REMOVER ESTE PRODUTO DO CATÁLOGO?'))) return;
|
||||
try { await HybridBackend.deleteProteseProduto(id); carregar(); } catch { toast.error('ERRO AO REMOVER.'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Tags size={16} className="text-violet-600" /> Meus produtos de prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-2">
|
||||
{/* Adicionar */}
|
||||
<div className="flex gap-2 items-end bg-gray-50 rounded-xl p-3">
|
||||
<div className="flex-1"><label className="text-[9px] font-black text-gray-500 uppercase">Produto</label><input value={novo.nome} onChange={e => setNovo({ ...novo, nome: e.target.value })} placeholder="Coroa de Zircônia" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<div className="w-20"><label className="text-[9px] font-black text-gray-500 uppercase">Preço</label><input type="number" value={novo.preco} onChange={e => setNovo({ ...novo, preco: e.target.value })} placeholder="0" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<div className="w-16"><label className="text-[9px] font-black text-gray-500 uppercase">Gar.(m)</label><input type="number" value={novo.garantia_meses} onChange={e => setNovo({ ...novo, garantia_meses: e.target.value })} className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<button onClick={add} className="p-2 rounded-lg bg-violet-600 text-white"><Plus size={16} /></button>
|
||||
</div>
|
||||
{/* Lista */}
|
||||
{itens.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-100">
|
||||
{editId === p.id ? (
|
||||
<>
|
||||
<input value={edit.nome} onChange={e => setEdit({ ...edit, nome: e.target.value })} className="flex-1 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<input type="number" value={edit.preco} onChange={e => setEdit({ ...edit, preco: e.target.value })} className="w-20 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<input type="number" value={edit.garantia_meses} onChange={e => setEdit({ ...edit, garantia_meses: e.target.value })} className="w-14 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<button onClick={() => salvarEdit(p.id)} className="text-green-600"><Check size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm font-bold text-gray-700">{p.nome}</span>
|
||||
<span className="text-sm text-gray-500">{fmtBRL(p.preco)}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.garantia_meses}m</span>
|
||||
<button onClick={() => { setEditId(p.id); setEdit({ nome: p.nome, preco: p.preco, garantia_meses: p.garantia_meses }); }} className="text-gray-300 hover:text-violet-600"><Pencil size={14} /></button>
|
||||
<button onClick={() => remover(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={14} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{itens.length === 0 && <p className="text-center text-gray-400 text-sm py-6">Nenhum produto. Adicione acima.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── View principal ──────────────────────────────────────────────────────────
|
||||
export const ProteseView: React.FC = () => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
const [detalhe, setDetalhe] = useState<string | null>(null);
|
||||
const [produtosModal, setProdutosModal] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
setLoading(true);
|
||||
const p = modo === 'bancada'
|
||||
? HybridBackend.getProteseBancada(filtro || undefined)
|
||||
: HybridBackend.getProteseOS(filtro ? { status: filtro } : {});
|
||||
p.then(r => setLista(Array.isArray(r) ? r : [])).catch(() => setLista([])).finally(() => setLoading(false));
|
||||
}, [modo, filtro]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const excluir = async (id: string) => {
|
||||
if (!(await confirm('EXCLUIR ESTA OS?'))) return;
|
||||
try { await HybridBackend.deleteProteseOS(id); toast.success('OS EXCLUÍDA.'); carregar(); }
|
||||
catch { toast.error('ERRO AO EXCLUIR.'); }
|
||||
};
|
||||
|
||||
const STATUS_FILTRO = modo === 'bancada'
|
||||
? ['', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue']
|
||||
: ['', 'solicitado', 'recebido', 'producao', 'finalizado', 'entregue', 'cancelado'];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
title={modo === 'bancada' ? 'Bancada de Próteses' : 'Próteses'}
|
||||
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
|
||||
>
|
||||
{modo === 'clinica' ? (
|
||||
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
|
||||
<Plus size={16} /> Nova OS
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
|
||||
<button onClick={carregar} className="flex items-center gap-2 border border-gray-200 text-gray-500 px-3 py-2 rounded-xl font-black text-xs uppercase"><RefreshCw size={14} /> Atualizar</button>
|
||||
</>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Filtros de status */}
|
||||
<div className="flex gap-2 flex-wrap mb-4 mt-2">
|
||||
{STATUS_FILTRO.map(s => (
|
||||
<button key={s} onClick={() => setFiltro(s)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase ${filtro === s ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{s === '' ? 'Todas' : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-400 py-12 text-sm">Carregando...</p>
|
||||
) : lista.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<FlaskConical size={40} className="mx-auto text-gray-200 mb-3" />
|
||||
<p className="text-gray-400 text-sm font-bold uppercase">{modo === 'bancada' ? 'Nenhum trabalho na bancada' : 'Nenhuma OS de prótese'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lista.map(os => (
|
||||
<div key={os.id} onClick={() => setDetalhe(os.id)}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-4 flex items-center gap-3 hover:border-violet-200 cursor-pointer transition-colors">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${os.atrasada ? 'bg-red-100' : 'bg-violet-100'}`}>
|
||||
<FlaskConical size={18} className={os.atrasada ? 'text-red-500' : 'text-violet-600'} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}
|
||||
<Badge s={os.status} />
|
||||
{modo === 'clinica' && os.status !== 'entregue' && (
|
||||
<button onClick={e => { e.stopPropagation(); excluir(os.id); }} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
)}
|
||||
<ChevronRight size={16} className="text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
|
||||
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
|
||||
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProteseView;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, LogOut, Stethoscope, Clock, CheckCircle2, ArrowRight, MapPin, Users, Wrench, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { APP_VERSION } from '../constants.ts';
|
||||
import { useAppVersion } from '../buildInfo.ts';
|
||||
|
||||
const UF_LIST = ['AC','AL','AM','AP','BA','CE','DF','ES','GO','MA','MG','MS','MT','PA','PB','PE','PI','PR','RJ','RN','RO','RR','RS','SC','SE','SP','TO'];
|
||||
|
||||
@@ -13,6 +13,7 @@ const ROLE_CONFIG: Record<string, { label: string; icon: React.ElementType; colo
|
||||
};
|
||||
|
||||
export const WaitingInviteView: React.FC = () => {
|
||||
const ver = useAppVersion();
|
||||
const userData = localStorage.getItem('SCOREODONTO_USER_DATA');
|
||||
const user = userData ? JSON.parse(userData) : null;
|
||||
const role = user?.role ?? 'dentista';
|
||||
@@ -229,7 +230,7 @@ export const WaitingInviteView: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-4 bg-gray-50 border-t border-gray-100 flex items-center justify-center">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{APP_VERSION}</span>
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{ver.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Increments the patch number in frontend/constants.ts before every build.
|
||||
// Called via: node ../update-version.js (from inside frontend/)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const constantsPath = path.join(__dirname, 'frontend', 'constants.ts');
|
||||
const content = fs.readFileSync(constantsPath, 'utf8');
|
||||
|
||||
const match = content.match(/APP_VERSION\s*=\s*['"]V(\d+)\.(\d+)\.(\d+)['"]/);
|
||||
if (!match) {
|
||||
console.error('[update-version] Could not parse APP_VERSION in constants.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [, major, minor, patch] = match;
|
||||
const next = `V${major}.${minor}.${parseInt(patch, 10) + 1}`;
|
||||
const updated = content.replace(
|
||||
/APP_VERSION\s*=\s*['"]V\d+\.\d+\.\d+['"]/,
|
||||
`APP_VERSION = '${next}'`
|
||||
);
|
||||
|
||||
fs.writeFileSync(constantsPath, updated, 'utf8');
|
||||
console.log(`[update-version] ${match[0].match(/V\d+\.\d+\.\d+/)[0]} → ${next}`);
|
||||
Reference in New Issue
Block a user