Compare commits

..

7 Commits

Author SHA1 Message Date
VPS 4 Builder f2eb90ccc8 feat(protese): módulo de OS de prótese (clínica ↔ laboratório/protético)
build-and-push / build (push) Successful in 54s
build-and-push / deploy (push) Successful in 3m38s
- backend: tabelas protese_os/_evento/_foto/_produtos + endpoints /api/protese/*
  (laboratorios, os, bancada, status, foto/raw assinado, produtos); catálogo do protético;
  custo de lab → DESPESA na entrega (idempotente, estorno no delete) + garantia com foto.
- Acesso: helper proteticoAcessivelPelaClinica (interno por vínculo OU diretório) valida o POST
  e o catálogo; catálogos passam por tenantGuard; correção do label da notificação (tipo resolvido).
- Frontend: ProteseView (Bancada do protético / Minhas OS da clínica) + rota /proteses
  + item de menu PRÓTESES no Sidebar (dentista/protético/funcionário).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:20:44 +02:00
VPS 4 Builder a991f1fe2c feat(version): versão em runtime via /api/version (Passos 1/3/4)
- buildInfo.ts: hook useAppVersion() consome GET /api/version (fonte única = backend),
  com fallback ao carimbo do bundle; deixa de depender do APP_VERSION manual.
- Versão exibida em Configurações ("Sobre o sistema") + telas públicas (Login/WaitingInvite);
  removida do rodapé do Sidebar.
- compose.prod/deploy-prod.sh: injetam APP_VERSION (=tag) e APP_ENV=PROD em runtime,
  desacoplando a versão semântica do build (commit/build seguem da imagem).
- constants.ts marcado legado + bump V1.0.15 (transitório até o Passo 5; evita colisão de tag).
- DEPLOY-PRODUCAO.md reescrito com status /🧪/🎯 + nota de fase transitória.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 05:19:27 +02:00
VPS 4 Builder 6e81718725 fix(ui): sino de notificações não sobrepõe mais modais/telas
build-and-push / build (push) Successful in 57s
build-and-push / deploy (push) Has been skipped
- remove z-[1002] do wrapper sempre-presente do sino (furava modais z-50..z-120)
- z aplicado só ao dropdown aberto (z-50) + backdrop (z-40)
- doc: RX apontado ao banco dev isolado

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:11:10 +02:00
VPS 4 Builder deaddfbf68 feat(dev-db): backend dev usa banco isolado via .env (default=produção)
build-and-push / build (push) Successful in 1m10s
build-and-push / deploy (push) Has been skipped
- DATABASE_URL parametrizado (${SCOREO_DB_*}); default mantém produção (VPS1) intacta
- server.js: não força SSL p/ host @pgdev (container dev local); SSL segue p/ VPS3
- .gitignore: ignora .env (override local de dev)
- doc: status de implementação do banco DEV isolado

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:45:53 +02:00
VPS 4 Builder efa9553536 docs: estratégia de banco DEV (relatório + plano) — 1 container PostGIS multi-DB
build-and-push / build (push) Successful in 1m16s
build-and-push / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:18:38 +02:00
VPS 4 Builder 96b857e408 docs: backlog de pendências (inclui isolar scoreodonto_dev) + valida CI rootless
build-and-push / build (push) Successful in 1m47s
build-and-push / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:08:59 +02:00
VPS 4 Builder a89eed3ba6 docs: atualiza auditoria e runbook para o fluxo CI/CD por tag (Etapa 6 concluída)
build-and-push / build (push) Successful in 1m17s
build-and-push / deploy (push) Has been skipped
- VERDADE-HOJE.md: deploy/rollback/containers/versão refletem CI/CD por tag + registry
- DEPLOY-PRODUCAO.md: fluxo canônico = deploy por tag; manual vira fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:15:19 +02:00
19 changed files with 1402 additions and 174 deletions
+3
View File
@@ -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/
+386 -1
View File
@@ -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
@@ -452,6 +452,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 +522,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 +3648,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 +5458,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
View File
@@ -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
+15 -4
View File
@@ -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
View File
@@ -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
+160
View File
@@ -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 (~300600 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 ~50100 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 (~50100 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 821 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`.
+108 -124
View File
@@ -1,158 +1,142 @@
# DEPLOY-PRODUCAO — Runbook operacional (estado de HOJE)
# Deploy & Versionamento — Runbook ScoreOdonto
> 🛑 **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`.
> Auditado contra o sistema em **17/jun/2026**. Cada item carrega seu **status real** — o doc
> responde *"o que existe hoje?"*, não *"o que gostaríamos?"*.
>
> ⚠️ **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.
> **Legenda de status (convenção do projeto):**
> - ✅ **EM PRODUÇÃO** — está no ar em `scoreodonto.com` (VPS1), verificável agora.
> - 🧪 **EM DEV** — implementado no working tree da VPS4 / `dev.scoreodonto.com`; **não commitado, não promovido**.
> - 🎯 **ALVO** — desenhado/aprovado, **ainda não implementado**.
>
> **Produção AGORA:** `v1.0.14 · commit 081985c · 2026-06-16` (`curl https://scoreodonto.com/api/version`).
> Todo o trabalho de versionamento abaixo marcado 🧪 está no working tree da VPS4 (não commitado).
---
## Mapa rápido (quem é quem)
## 1. O modelo
| 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 | — |
> **Push** na `main` produz imagem por **commit** no registry; a **tag `vX.Y.Z`** **promove** essa imagem para produção. **Produção nunca builda — só executa imagens.**
- 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-*`).
```
PUSH main (commit C) TAG vX.Y.Z (no commit C)
│ │
▼ ▼
CI: build CI: deploy
publica :C, :<versão>, :latest ✅ hoje: REBUILDA + promove + deploya
▼ 🎯 alvo (Passo 5): só re-tag :C→:vX.Y.Z, SEM build
registry ▼
VPS1: pull + up --no-build ✅
APP_VERSION=tag em runtime 🧪 (Passo 3)
```
---
## FASE 0 — Pré-voo (na VPS4)
## 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: `act_runner` "vps4-builder" builda+publica em push | ✅ | `.gitea/workflows/build.yml`; runner systemd na VPS4 |
| Deploy por tag (job `deploy` SSH VPS4→VPS1) | ✅ | `deploy-prod.sh` na VPS1 |
| 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` |
| `GET /api/version` responde (de `process.env`) | ✅ | Em prod, valores vêm do **build** da imagem |
| **Versão exibida em Configurações → "Sobre o sistema"** | 🧪 | Passo 1. Em **produção a versão está no Sidebar** |
| **Front/telas consomem `/api/version` (`useAppVersion`); app não usa `APP_VERSION`** | 🧪 | Passos 1/4. Em **prod o front usa `APP_VERSION` do bundle** |
| **Deploy injeta `APP_VERSION`/`APP_ENV` em runtime (`environment`)** | 🧪 | Passo 3 (`compose.prod`/`deploy-prod.sh` no working tree) |
| **Tag promove sem rebuild + validação de integridade** | 🎯 | Passo 5 (§7) |
---
## 3. Mapa de máquinas ✅
| Onde | Host | Papel | Docker |
|---|---|---|---|
| VPS1 | `10.99.0.1` | **Produção** (Traefik + apps) | rootless (`unix:///run/user/1000/docker.sock`) |
| VPS3 | `10.99.0.3` | Postgres (prod) + Gitea + registry | — |
| VPS4 | `10.99.0.4` | **Dev + Builder** (código; runner CI; `pgdev`) | rootless |
- Repo (fonte da verdade): `ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git`.
- Project Compose: `scoreodontocom`. Diretório nas duas máquinas: `/home/deploy/stack/scoreodonto.com`.
---
## 4. De onde vem a versão (contrato `/api/version`)
```json
{ "name": "ScoreOdonto API", "version": "v1.0.14", "commit": "081985c",
"build": "2026-06-16 08:06 UTC", "environment": "PROD" }
```
- **commit / build** = carimbados no **build** da imagem (imutáveis — identidade do artefato). ✅
- **version / environment** — **hoje (✅)** também vêm do build da imagem (build arg no CI). **🧪 (Passo 3):** passam a ser injetados no **deploy** em runtime (a partir da tag), liberando a mesma imagem para qualquer tag sem rebuild.
- **Frontend** — **🧪 (Passos 1/4):** lê tudo de `/api/version` via `useAppVersion()`; deixa de embutir versão. **Em produção** ainda embute `APP_VERSION` no bundle.
---
## 5. Runbook — subir um release (fluxo ATUAL ✅)
> ⚠️ **Regra de ouro:** confirme **DEV ou PRODUÇÃO** antes de agir. `dev.scoreodonto.com` = VPS4 (banco `pgdev`, descartável). `scoreodonto.com` = VPS1 (real). **Nunca** `git push --force` na `main`. Push/tag exigem autorização explícita.
```bash
# 1) VPS4: validar em DEV (build local do container de dev)
cd /home/deploy/stack/scoreodonto.com
# 1. Sanidade do backend
node --check backend/server.js
docker compose build scoreodonto-backend scoreodonto-frontend
docker compose up -d --force-recreate scoreodonto-backend scoreodonto-frontend
# conferir em https://dev.scoreodonto.com
# 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
```
# 2) ⚠️ transitório (até Passo 5): a TAG precisa casar com frontend/constants.ts
node update-version.js # bump (ex. V1.0.14 → V1.0.15)
```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)"
# 3) commit + push → CI builda e publica :<commit> + :<versão> + :latest (NÃO deploya)
git add -A && git commit -m "feat: <descrição> (vX.Y.Z)"
git push origin main
# 4) tag = "aprovado p/ produção" → CI (hoje REBUILDA ✅) promove + deploya na VPS1
git tag v1.0.15 && git push origin v1.0.15
# 5) verificar SEM SSH:
curl -s https://scoreodonto.com/api/version # → version/commit/environment
```
> ❗ Push **não** dispara deploy automático hoje (não há listener/webhook ativo). A promoção é a FASE 3.
**Backup do banco de PRODUÇÃO antes de releases sensíveis** (migrações rodam no boot; aditivas/idempotentes):
```bash
ssh deploy@10.99.0.3 'pg_dump -Fc scoreodonto -f ~/backups/scoreodonto_$(date +%Y%m%d_%H%M).dump'
```
> ### ⚠️ Fase transitória — por que `APP_VERSION`/`constants.ts` ainda existem
> Estamos entre o **modelo antigo** (versão escrita à mão em `constants.ts`) e o **novo**
> (identidade pelo **commit**; tags só promovem). Até o **Passo 5**, o CI ainda lê `APP_VERSION`
> para nomear a imagem → o bump manual continua **necessário para evitar colisão de tags no
> registry** (republicar `:vX.Y.Z` com outro código quebraria *"uma tag = um artefato"*).
> **Após o Passo 5:** `constants.ts` e `update-version.js` são removidos, a identidade passa a
> ser o commit, e tags apenas **promovem** artefatos já construídos.
---
## FASE 3 — Promover para produção (na VPS1)
## 6. Rollback ✅
Reverter o **código** é seguro (migrações só adicionam). Por imagem, **sem rebuild**:
```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
/home/deploy/stack/scoreodonto.com/deploy-prod.sh v1.0.13 # tag anterior → pull + up --no-build
```
> 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 ~1030s no boot (reconecta Postgres/Dragonfly e roda as migrações idempotentes).
Restaurar o dump do banco só em caso extremo (`pg_restore --clean`).
---
## FASE 4 — Verificação pós-deploy (na VPS1)
## 7. Passo 5 — "tag promove, não builda" 🎯
```bash
export DOCKER_HOST=unix:///run/user/1000/docker.sock
Desenho **aprovado (17/jun/2026), não aplicado**. Refina o CI existente para Build Once → Promote → Deploy:
- `build.yml` → 2 jobs por ref: **build** (só push; publica `:<commit>`+`:latest`, sem `APP_VERSION`); **promote** (só tag; re-tag `:<commit>→:vX.Y.Z` via `docker buildx imagetools create`, sem rebuild) → deploy.
- **Validação de integridade antes do deploy:** existe `backend:<C>` e `frontend:<C>`? digests válidos? alias `:vX.Y.Z` == digest de `:<C>`? front e back do **mesmo commit**? Só então deploy (evita deploy parcial).
- Decisões fechadas: manter **`:<commit>`** (sem prefixo); **falhar** se o commit não foi pushado (nunca buildar na tag); **`:latest`** só conveniência.
- Eliminar `update-version.js` + `frontend/constants.ts` (a versão passa a nascer da **tag git**).
- Follow-ups: `GET /api/health`; **política de retenção** do registry.
# 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`.
Racional completo em `deploy.md` e na memória `project-cicd-tag-promove-nao-rebuilda`.
---
## 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
```
> **Sequência acordada para fechar o drift:** (1) doc honesta com status ✅/🧪/🎯 [este doc] → (2) commitar Passos 1/3/4 → (3) promover → (4) confirmar `/api/version` → (5) elevar `VERDADE-HOJE.md` a doc principal (com esta convenção e precedência) → (6) Passo 5 → (7) atualizar docs. À medida que cada item 🧪 for promovido, vira ✅ aqui.
+76
View File
@@ -0,0 +1,76 @@
# Pendências & Tarefas a Fazer — ScoreOdonto
> Backlog operacional/infra após as 6 etapas de CI/CD (16/jun/2026). Ordenado por prioridade.
> Contexto da infra em `arquitetura.md`; fluxo de deploy em `deploy.md`; verdade atual em `VERDADE-HOJE.md`.
---
## 🔴 P0 — Estrutural
### 1. Isolar os bancos DEV (dev parar de usar o banco de produção)
> **Estratégia decidida + plano completo em `BANCO-DEV-ESTRATEGIA.md`**: um único container `postgis/postgis` na VPS4 (rootless) com `*_ref` (template da VPS3) + `*_dev`. Resolve scoreodonto, RX e subdomínio (e futuros) numa instância só. O texto abaixo é o contexto original.
**Problema:** dev (VPS4) e produção (VPS1) apontam para o **mesmo** banco na VPS3 (scoreodonto, dental_images, subdominio). Qualquer teste em dev escreve no dado real. É a maior dívida estrutural restante.
**Bloqueador descoberto (16/jun/2026):** o role `scoreodonto_user` **não pode criar banco** (`CREATEDB=false`, `SUPER=false`) e **não há shell na VPS3** (`deploy@10.99.0.3` recusa a chave). → Precisa de **ação do operador com acesso superuser** ao Postgres da VPS3 (usuário `postgres`), OU conceder a permissão: `ALTER ROLE scoreodonto_user CREATEDB;`.
**Passos:**
1. **(operador/superuser na VPS3)** Criar o banco:
`CREATE DATABASE scoreodonto_dev OWNER scoreodonto_user;`
2. **Popular com cópia da produção** (dados realistas p/ dev):
- Já existe dump recente em `/home/deploy/backups/scoreodonto_*.dump` (VPS4, formato `-Fc`).
- Restaurar: `pg_restore -d "postgresql://scoreodonto_user:<senha>@10.99.0.3:5432/scoreodonto_dev" --no-owner /caminho/scoreodonto_*.dump`
(ou via container: `docker run --rm -v <dump>:/b.dump -e PGPASSWORD=… postgres:18 pg_restore -h 10.99.0.3 -U scoreodonto_user -d scoreodonto_dev --no-owner /b.dump`).
3. **Apontar SÓ o dev para `scoreodonto_dev`** (sem tocar na prod):
- No `docker-compose.yml`, parametrizar: `DATABASE_URL=…@10.99.0.3:5432/${SCOREODONTO_DB:-scoreodonto}?schema=public` (default mantém a prod intacta).
- Na VPS4, criar `.env` (gitignored, dev-only) com `SCOREODONTO_DB=scoreodonto_dev`.
- Rebuild + recreate do backend de dev.
4. **Verificar:** `dev.scoreodonto.com/api/version` sobe; conferir que escrita em dev cai em `scoreodonto_dev` e **não** em `scoreodonto` (contagens divergem após um teste).
5. **(privacidade)** Avaliar anonimizar dados sensíveis de pacientes no `scoreodonto_dev`.
**Ganho:** dev deixa de poder corromper o dado de produção; fim da “única rede de segurança é o backup”.
---
## 🟠 P1 — Segurança
### 2. Rotacionar o token do Gitea exposto no chat
O token usado nos `docker login` (VPS4 e VPS1) e na descoberta foi colado no chat → comprometido. **Revogar no Gitea** (Configurações → Aplicações) e gerar um novo. Os logins já estão no keystore do Docker; só re-logar quando o token for trocado.
### 3. Criar o secret `REGISTRY_TOKEN` no repo
Adicionar em `git.clube67.com` → repo → Settings → Actions → Secrets um token (escopo `write:package`/`read:package`). Hoje o workflow funciona com a credencial do host como fallback; o secret deixa o CI independente disso.
### 4. Tirar credenciais em texto puro do `docker-compose.yml`
O `DATABASE_URL` e `DRAGONFLY_URL` têm **senha em texto puro** versionada no repo. Mover para `.env`/secrets (e idealmente rotacionar as senhas). Dívida de segurança pré-existente.
---
## 🟡 P2 — Operacional / Cosmético
### 5. Retenção/limpeza de imagens no registry
O registry vai acumular imagens (`:vX.Y.Z`, `:<commit>`, `:latest`) a cada deploy. Definir política de retenção (ex.: manter N últimas + releases) — a VPS3 tem disco finito. Avaliar limpeza automática.
### 6. Limpar a árvore git suja da VPS1
O repo em `/home/deploy/stack/scoreodonto.com` na VPS1 está **sujo** (vários `.md` e `.dockerignore` deletados, HEAD antigo `19dbbae`). Não afeta o deploy (que usa imagem), mas convém reconciliar/limpar para não confundir.
### 7. Ruído de attestation no registry
O BuildKit publica manifests de *provenance/attestation* que aparecem como “tags” `sha256:…` no registry. Inócuo; desligar com `provenance=false`/`--provenance=false` no build se incomodar.
### 8. Padronizar o case da versão (V vs v)
A CI mostra `v1.0.14` (minúsculo, por `tr 'V' 'v'`); builds manuais mostram `V1.0.14`. Cosmético — alinhar para um padrão único.
### 9. Backup automatizado do banco
Hoje o `pg_dump` é manual (via container `postgres:18`). Agendar backup periódico do `scoreodonto` (e, após P0, do `scoreodonto_dev`) com retenção. Evolução: WAL archiving / PITR.
---
## 🔵 Futuro (visão de produto — fora do escopo de infra)
Itens herdados do mapa de produto, ainda não implementados:
- **Score Professional** (motor de pontuação) — não existe.
- **Estoque**, **Teleatendimento**.
- Entidade formal **Pessoa × Empresa**.
- Monitoramento/observabilidade (Grafana já existe na stack — integrar métricas do ScoreOdonto).
---
_Atualizar este arquivo conforme as pendências forem resolvidas. As 6 etapas de CI/CD (documentação, versão visível, registry, pipeline, deploy por tag) estão **concluídas** — ver `deploy.md`._
+9 -10
View File
@@ -18,29 +18,28 @@
### 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`).
- `scoreodontocom-scoreodonto-frontend-1`roda **imagem versionada do registry** (`git.clube67.com/ruicesar/scoreodonto-frontend:<tag>`), promovida por deploy-por-tag. Produção **não builda** mais.
- `scoreodontocom-scoreodonto-backend-1`idem (`: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 46).
- **CI/CD por tag (Gitea Actions).** `git tag vX.Y.Z && git push origin vX.Y.Z` → o runner `vps4-builder` builda as imagens, publica no registry e o job `deploy` promove para a VPS1 (`deploy-prod.sh`). **Push na main NÃO deploya — só builda.**
- **Produção não builda mais:** consome a **imagem versionada** do registry (`docker compose -f docker-compose.yml -f docker-compose.prod.yml pull && up -d --no-build`).
- Deploy manual também disponível: na VPS1, `./deploy-prod.sh <tag>`. Ver `deploy.md` e `REGISTRY.md`.
### 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.**
-**Versão visível** (Etapa 3): rodapé do Sidebar + `GET /api/version` + log de boot `[VERSION] …`. Carimbo (versão/commit/data/ambiente) injetado no build. **No ar em DEV e em PRODUÇÃO** (prod hoje: `v1.0.14 · 081985c · PROD`).
### 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**.
- **Por imagem, sem rebuild:** na VPS1, `./deploy-prod.sh <tag-anterior>` (ex.: `./deploy-prod.sh v1.0.13`) — puxa a imagem da tag anterior do registry e sobe. Minutos, sem recompilar.
- Seguro porque as migrações são **aditivas** (voltar código não quebra o schema). Restaurar o dump (`/home/deploy/backups/`) só em caso extremo.
---
## 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 36 vão eliminar.
> O código está salvo no Gitea (VPS3) e roda em containers na VPS1 (prod) e VPS4 (dev), ambos no **mesmo banco** da VPS3. **CI/CD por tag ativo**: `git tag vX.Y.Z` → CI builda imagem → deploy automático na VPS1, com **versão visível** no rodapé/`/api/version` e **rollback por imagem** (`deploy-prod.sh <tag-anterior>`). Pendência estrutural restante: **isolar um banco `scoreodonto_dev`** (dev e prod ainda compartilham o banco).
+8 -3
View File
@@ -47,6 +47,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 +60,7 @@ export type ViewKey =
| 'financeiro'
| 'comissoes'
| 'glosas'
| 'proteses'
| 'login'
| 'update'
| 'dentistas'
@@ -147,6 +149,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 +201,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 */
@@ -293,10 +297,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 +459,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 />;
+24 -6
View File
@@ -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;
}
+3 -3
View File
@@ -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>
+4 -13
View File
@@ -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>
);
+5 -1
View File
@@ -1 +1,5 @@
export const APP_VERSION = 'V1.0.14';
// LEGADO (Passo 4): a APLICAÇÃO não usa mais este valor — a versão exibida vem do backend
// (/api/version) em runtime via useAppVersion(). Permanece apenas porque o PIPELINE ainda o lê
// (update-version.js + .gitea/workflows/build.yml). Será eliminado no Passo 5, quando a versão
// passar a nascer da TAG git. Não referenciar no código do frontend.
export const APP_VERSION = 'V1.0.15';
+73
View File
@@ -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) });
+27 -2
View File
@@ -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>
);
+4 -3
View File
@@ -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>
)}
+491
View File
@@ -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;
+3 -2
View File
@@ -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>