feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório

Provedor de Prótese, Fase 2 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência
  (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir.
  UI: badges de direção e conferência + botões inline na aba Itens & Valores.
- Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada,
  retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id).
- Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final.
  Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba
  "Ocorrências" (com contador) no modal da OS.
- Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 04:53:33 +02:00
parent 7ae6993c4b
commit 3ffaff9d7d
4 changed files with 271 additions and 24 deletions
+117 -9
View File
@@ -3976,8 +3976,10 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId); const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' }); 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: 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, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]); const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL 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: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at, COALESCE(direcao, $2) AS direcao, COALESCE(status_conferencia, $3) AS status_conferencia, conferido_nome, conferido_em FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id, 'enviado', 'pendente']);
const { rows: ocorrencias } = await pool.query('SELECT id, componente_id, categoria, descricao, responsavel, resposta, resposta_nome, status, autor_nome, created_at, resolved_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [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: 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]); 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]);
// Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve. // Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve.
@@ -3993,6 +3995,10 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
componentes, componentes,
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })), pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
custodia, custodia,
ocorrencias: ocorrencias.map(o => ({
...o,
fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
})),
}); });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -4038,10 +4044,11 @@ app.post('/api/protese/os/:id/componentes', authGuard, async (req, res) => {
const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid); const nome = await actorNome(uid);
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1); const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
const direcao = b.direcao === 'devolvido' ? 'devolvido' : 'enviado';
await pool.query( await pool.query(
`INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, `INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome, direcao) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome]); [id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome, direcao]);
await logEventoOS(acc.os, uid, `Componente enviado: ${qtd}× ${String(b.nome).trim()}`, 'componente'); await logEventoOS(acc.os, uid, `Componente ${direcao}: ${qtd}× ${String(b.nome).trim()}`, 'componente');
res.json({ success: true, id }); res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -4177,6 +4184,79 @@ app.post('/api/protese/os/:id/trocar', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// ── FASE 2: rastreabilidade (conferência) + ocorrências (contraditório) ──────
const OCORRENCIA_CATS = ['nao_devolvido', 'danificado', 'incompativel', 'parafuso_substituido', 'parafuso_desgastado', 'peca_incorreta', 'ajuste_inadequado', 'falha_fabricacao', 'retrabalho', 'outro'];
// Conferir um componente (ok | divergente | ausente | pendente).
app.post('/api/protese/os/componentes/:id/conferir', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
const st = ['ok', 'divergente', 'ausente', 'pendente'].includes(req.body?.status_conferencia) ? req.body.status_conferencia : null;
if (!st) return res.status(400).json({ error: 'status_conferencia inválido.' });
const nome = await actorNome(uid);
await pool.query('UPDATE protese_os_componente SET status_conferencia=$1, conferido_nome=$2, conferido_em=NOW() WHERE id=$3', [st, nome, req.params.id]);
await logEventoOS(acc.os, uid, `Componente conferido (${st}): ${rows[0].nome}`, 'componente');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Abrir ocorrência.
app.post('/api/protese/os/:id/ocorrencias', 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 b = req.body || {};
if (!OCORRENCIA_CATS.includes(b.categoria)) return res.status(400).json({ error: 'Categoria inválida.' });
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva a ocorrência.' });
const id = `poco_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid);
await pool.query(
`INSERT INTO protese_os_ocorrencia (id, os_id, clinica_id, componente_id, categoria, descricao, responsavel, status, autor_id, autor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,'aberta',$8,$9)`,
[id, acc.os.id, acc.os.clinica_id, b.componente_id || null, b.categoria, String(b.descricao).trim(), b.responsavel || null, uid, nome]);
await logEventoOS(acc.os, uid, `Ocorrência aberta: ${b.categoria}`, 'ocorrencia');
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Responder ocorrência (contraditório) → 'respondida'.
app.post('/api/protese/os/ocorrencias/:id/resposta', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida.' });
if (!req.body?.resposta || !String(req.body.resposta).trim()) return res.status(400).json({ error: 'Escreva a resposta.' });
const nome = await actorNome(uid);
await pool.query("UPDATE protese_os_ocorrencia SET resposta=$1, resposta_nome=$2, status=CASE WHEN status='aberta' THEN 'respondida' ELSE status END WHERE id=$3", [String(req.body.resposta).trim(), nome, req.params.id]);
await logEventoOS(acc.os, uid, `Ocorrência respondida: ${rows[0].categoria}`, 'ocorrencia');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Mudar status da ocorrência (em_analise | resolvida | contestada). 'resolvida' é final.
app.post('/api/protese/os/ocorrencias/:id/status', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const novo = req.body?.status;
if (!['em_analise', 'resolvida', 'contestada'].includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida (final).' });
await pool.query("UPDATE protese_os_ocorrencia SET status=$1, resolved_at=CASE WHEN $1='resolvida' THEN NOW() ELSE resolved_at END WHERE id=$2", [novo, req.params.id]);
await logEventoOS(acc.os, uid, `Ocorrência ${novo}: ${rows[0].categoria}`, 'ocorrencia');
res.json({ success: true, status: novo });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Mudar status (laboratório avança a produção; clínica entrega/cancela). // Mudar status (laboratório avança a produção; clínica entrega/cancela).
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => { app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
const novo = req.body?.status; const novo = req.body?.status;
@@ -4257,12 +4337,13 @@ app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async
const uid = req.authUser.userId; const uid = req.authUser.userId;
const nome = await actorNome(uid); const nome = await actorNome(uid);
const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null; const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null;
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', const ocorrenciaId = req.body?.ocorrencia_id || null; // foto de evidência de uma ocorrência
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda]); await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda, ocorrencia_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda, ocorrenciaId]);
// Anexar foto entra no histórico (rastreabilidade: quem anexou e quando). // Anexar foto entra no histórico (rastreabilidade: quem anexou e quando).
await pool.query( await pool.query(
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'foto',$4,$5,$6)`, `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, legenda ? `Foto anexada: ${legenda}` : 'Foto anexada', uid, nome]); [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, ocorrenciaId ? 'ocorrencia' : 'foto', ocorrenciaId ? 'Evidência anexada à ocorrência' : (legenda ? `Foto anexada: ${legenda}` : 'Foto anexada'), uid, nome]);
res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` }); 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 }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -5987,6 +6068,33 @@ async function runMigrations() {
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`, `CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
// ── FASE 2: Rastreabilidade de componentes + Ocorrências (com contraditório) ──
// Componentes ganham direção (enviado clínica→lab | devolvido lab→clínica) e conferência.
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS direcao TEXT DEFAULT 'enviado'`,
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS status_conferencia TEXT DEFAULT 'pendente'`, // pendente|ok|divergente|ausente
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_nome TEXT`,
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_em TIMESTAMPTZ`,
// Ocorrências técnicas (parafuso trocado, peça danificada, retrabalho…). Contraditório:
// aberta → respondida → em_analise → resolvida | contestada. Só resolvidas pesam no score (Fase 5).
`CREATE TABLE IF NOT EXISTS protese_os_ocorrencia (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
componente_id TEXT,
categoria TEXT NOT NULL,
descricao TEXT,
responsavel TEXT,
resposta TEXT,
resposta_nome TEXT,
status TEXT DEFAULT 'aberta',
autor_id TEXT,
autor_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`,
// Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id).
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`,
`CREATE TABLE IF NOT EXISTS protese_produtos ( `CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético) protetico_id TEXT NOT NULL, -- dono (usuário protético)
+10 -2
View File
@@ -61,8 +61,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado.
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF. `paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo. - Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
### FASE 2 — Rastreabilidade & Ocorrências ### FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
Componentes recebidos/devolvidos/conferidos; tabela de ocorrências; evidências; contraditório. - **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
### FASE 3 — Operação multi-clínica ### FASE 3 — Operação multi-clínica
Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo. Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo.
+26 -3
View File
@@ -778,21 +778,44 @@ export const HybridBackend = {
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status'); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
return await res.json(); return await res.json();
}, },
uploadProteseFoto: async (id: string, file: File, legenda?: string) => { uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
const fd = new FormData(); const fd = new FormData();
fd.append('foto', file); fd.append('foto', file);
if (legenda) fd.append('legenda', legenda); if (legenda) fd.append('legenda', legenda);
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd }); 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'); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
return await res.json(); return await res.json();
}, },
// Conferência de componente (status: ok | divergente | ausente | pendente)
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
return await res.json();
},
// Ocorrências (contraditório)
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
return await res.json();
},
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
return await res.json();
},
statusProteseOcorrencia: async (ocId: string, status: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
return await res.json();
},
deleteProteseFoto: async (fotoId: string) => { deleteProteseFoto: async (fotoId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' }); const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto'); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
return await res.json(); return await res.json();
}, },
// Componentes enviados ao laboratório // Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string }) => { addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente'); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
return await res.json(); return await res.json();
+115 -7
View File
@@ -180,7 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
const confirm = useConfirm(); const confirm = useConfirm();
const [os, setOs] = useState<any>(null); const [os, setOs] = useState<any>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor'>('etapas'); const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas');
const carregar = useCallback(() => { const carregar = useCallback(() => {
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.')); HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
@@ -268,6 +268,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{ id: 'etapas', label: 'Etapas' }, { id: 'etapas', label: 'Etapas' },
{ id: 'itens', label: 'Itens & Valores' }, { id: 'itens', label: 'Itens & Valores' },
{ id: 'monitor', label: 'Monitoramento' }, { id: 'monitor', label: 'Monitoramento' },
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
{ id: 'timeline', label: 'Linha do tempo' }, { id: 'timeline', label: 'Linha do tempo' },
] as const; ] as const;
@@ -383,6 +384,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
)} )}
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />} {tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
</div> </div>
</div> </div>
</div> </div>
@@ -458,6 +460,96 @@ const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged:
); );
}; };
// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ────────────
const OCOR_CATS: Record<string, string> = {
nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível',
parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta',
ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro',
};
const OCOR_STATUS_COR: Record<string, string> = {
aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700',
resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700',
};
const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [cat, setCat] = useState('danificado');
const [desc, setDesc] = useState('');
const [resp, setResp] = useState('');
const [busy, setBusy] = useState(false);
const [respostas, setRespostas] = useState<Record<string, string>>({});
const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado';
const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase());
const abrir = async () => {
if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; }
setBusy(true);
try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); }
catch (e) { err(e); } finally { setBusy(false); }
};
const responder = async (id: string) => {
const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; }
setBusy(true);
try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); }
};
const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } };
const anexar = async (id: string, e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return; setBusy(true);
try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; }
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
</div>
{open && (
<div className="bg-red-50 rounded-xl p-3 space-y-2">
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
</div>
)}
<div className="space-y-3">
{(os.ocorrencias || []).map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
</div>
<p className="text-xs text-gray-600">{o.descricao}</p>
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
{o.status !== 'resolvida' && (
<div className="space-y-1.5 pt-1 border-t border-gray-50">
<div className="flex gap-1.5">
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
</div>
<div className="flex gap-1.5">
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
</div>
</div>
)}
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
</div>
))}
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
</div>
<p className="text-[9px] text-gray-300 uppercase font-bold"> ocorrências resolvidas pesarão em indicadores (futuro).</p>
</div>
);
};
// ── Seção: Monitoramento de custódia (onde o trabalho está) ───────────────── // ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => { const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
const noLab = (os.local_atual || 'clinica') === 'laboratorio'; const noLab = (os.local_atual || 'clinica') === 'laboratorio';
@@ -521,36 +613,52 @@ const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
}; };
// ── Seção: Componentes enviados ao laboratório ────────────────────────────── // ── Seção: Componentes enviados ao laboratório ──────────────────────────────
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => { const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast(); const toast = useToast();
const [nome, setNome] = useState(''); const [nome, setNome] = useState('');
const [qtd, setQtd] = useState('1'); const [qtd, setQtd] = useState('1');
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const add = async () => { const add = async () => {
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; } if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
setBusy(true); setBusy(true);
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1 }); setNome(''); setQtd('1'); onChanged(); } try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
}; };
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } }; const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return ( return (
<div> <div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Componentes enviados</p> <p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{(os.componentes || []).map((c: any) => ( {(os.componentes || []).map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2"> <div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
<span className="font-black text-gray-700">{c.quantidade}×</span> <span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span> <span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
<span className="text-[10px] text-gray-400">{c.enviado_nome} · {dataBR(c.created_at)}</span> <span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>} {podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div> </div>
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
))}
</div>
</div>
))} ))}
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>} {(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
</div> </div>
{podeEditar && ( {podeEditar && (
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-14 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" /> <select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Ex.: Implante, pilar, modelo..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> <option value="enviado">Enviado</option>
<option value="devolvido">Devolvido</option>
</select>
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button> <button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
</div> </div>
)} )}