feat(protese): modal da OS em abas + cupom de etapa + monitoramento de custódia

Reorganiza o gerenciamento da OS em ABAS (sem o scrollão único):
- Etapas: dados + stepper; substitui entregue/cancelar por "imprimir cupom" de cada
  etapa (comprovante imprimível que circula com o trabalho — janela de impressão).
- Linha do tempo: histórico com data/hora/autor; AQUI ficam marcar entregue / cancelar
  (e o fluxo do lab / acionar garantia).
- Itens & Valores: componentes, fotos e pagamentos.
- Monitoramento: custódia do trabalho (clínica ↔ laboratório) — "protético retirou" /
  "devolveu à clínica", com local atual em destaque e histórico de movimentações.

Backend: protese_os.local_atual + tabela protese_os_custodia + POST .../custodia
(valida transição, registra movimento e evento). GET detalhe expõe local_atual + custodia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 01:32:31 +02:00
parent 623c9990d6
commit 8cf4a83161
3 changed files with 216 additions and 77 deletions
+41
View File
@@ -3976,12 +3976,15 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]);
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
res.json({
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
local_atual: os.local_atual || 'clinica',
eventos,
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
componentes,
pagamentos: pagamentos.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
custodia,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -4082,6 +4085,28 @@ app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Custódia: registrar que o trabalho mudou de mãos (clínica ↔ laboratório).
app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const acc = await carregaOSComAcesso(req.params.id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
if (!para) return res.status(400).json({ error: "para_local deve ser 'clinica' ou 'laboratorio'." });
const de = acc.os.local_atual || 'clinica';
if (de === para) return res.status(409).json({ error: `O trabalho já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
const id = `pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid);
await pool.query(
`INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[id, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, req.body?.observacao || null, uid, nome]);
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
const rotulo = para === 'laboratorio' ? 'Protético retirou o trabalho (foi p/ o laboratório)' : 'Trabalho devolvido à clínica';
await logEventoOS(acc.os, uid, rotulo, 'custodia');
res.json({ success: true, id, local_atual: para });
} 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;
@@ -5874,6 +5899,22 @@ async function runMigrations() {
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
// Custódia: onde o trabalho físico está agora (clinica|laboratorio) + log de movimentações
// (saber se o protético "pegou" o trabalho ou se está na clínica).
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS local_atual TEXT DEFAULT 'clinica'`,
`CREATE TABLE IF NOT EXISTS protese_os_custodia (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
de_local TEXT,
para_local TEXT NOT NULL,
etapa TEXT,
observacao TEXT,
actor_id TEXT,
actor_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)