From 8cf4a831618aca88822a66f9583ae0319ed36c2d Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 01:32:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20modal=20da=20OS=20em=20abas=20?= =?UTF-8?q?+=20cupom=20de=20etapa=20+=20monitoramento=20de=20cust=C3=B3dia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 41 ++++++ frontend/services/backend.ts | 6 + frontend/views/ProteseView.tsx | 246 ++++++++++++++++++++++----------- 3 files changed, 216 insertions(+), 77 deletions(-) diff --git a/backend/server.js b/backend/server.js index 0b9a125..f5afe84 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 79a141d..c35a07f 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -813,6 +813,12 @@ export const HybridBackend = { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento'); return await res.json(); }, + // Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio') + moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação'); + 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' }); diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 446f346..b07ffcc 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History } from 'lucide-react'; +import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2 } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; @@ -181,6 +181,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose const confirm = useConfirm(); const [os, setOs] = useState(null); const [busy, setBusy] = useState(false); + const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor'>('etapas'); const carregar = useCallback(() => { HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.')); @@ -195,14 +196,6 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose finally { setBusy(false); } }; - const anexar = async (e: React.ChangeEvent) => { - 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); @@ -221,92 +214,191 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose finally { setBusy(false); } }; + const moverCustodia = async (para: 'clinica' | 'laboratorio') => { + setBusy(true); + try { await HybridBackend.moverProteseCustodia(id, para); toast.success(para === 'laboratorio' ? 'TRABALHO COM O LABORATÓRIO.' : 'TRABALHO NA CLÍNICA.'); carregar(); onChanged(); } + 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); + const noLab = (os.local_atual || 'clinica') === 'laboratorio'; + + // Cupom imprimível de uma etapa (comprovante que circula com o trabalho). + const imprimirCupom = (etapa: string) => { + const comps = (os.componentes || []).map((c: any) => `${c.quantidade}× ${c.nome}`).join(', ') || '—'; + const w = window.open('', '_blank', 'width=420,height=640'); if (!w) return; + w.document.write(`Cupom ${STATUS_LABEL[etapa] || etapa} +

ORDEM DE PRÓTESE

OS ${os.id} · ${new Date().toLocaleString('pt-BR')}
+
Etapa: ${STATUS_LABEL[etapa] || etapa}
+ + + + + + + + + +
Paciente${os.paciente_nome || '—'}
Trabalho${os.tipo || '—'}
Dentista${os.dentista_nome || '—'}
Dentes${os.dentes || '—'}
Material / Cor${os.material || '—'} / ${os.cor || '—'}
Componentes${comps}
Prazo${os.prazo_entrega ? dataBR(os.prazo_entrega) : '—'}
Local atual${noLab ? 'Laboratório' : 'Clínica'}
+ ${os.observacoes ? `
Obs.: ${os.observacoes}
` : ''} +
Entregue por
Recebido por
+ `); + w.document.close(); w.focus(); setTimeout(() => w.print(), 250); + }; + + const TABS = [ + { id: 'etapas', label: 'Etapas' }, + { id: 'timeline', label: 'Linha do tempo' }, + { id: 'itens', label: 'Itens & Valores' }, + { id: 'monitor', label: 'Monitoramento' }, + ] as const; return (
-
e.stopPropagation()}> -
-
+
e.stopPropagation()}> + {/* Header */} +
+

{os.tipo}

-

{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}

+

{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}

- -
-
-
+
- {os.tipo_os === 'garantia' && Garantia} - {os.atrasada && Atrasada} - {os.prazo_entrega && Prazo {dataBR(os.prazo_entrega)}} +
+
+ {/* Abas */} +
+ {TABS.map(t => ( + + ))} +
- {/* Stepper de etapas */} - - - {os.garantia_ate && ( -
- Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses) +
+ {tab === 'etapas' && ( +
+
+ {os.tipo_os === 'garantia' && Garantia} + {os.atrasada && Atrasada} + {os.prazo_entrega && Prazo {dataBR(os.prazo_entrega)}} + {noLab ? 'Com o laboratório' : 'Na clínica'} +
+ + {os.garantia_ate && (
Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
)} +
+ {os.material &&
Material{os.material}
} + {os.cor &&
Cor{os.cor}
} + {os.dentista_nome &&
Dentista{os.dentista_nome}
} + {modo === 'bancada' && Number(os.custo) > 0 &&
Custo lab{fmtBRL(os.custo)}
} + {modo === 'clinica' && Number(os.valor) > 0 &&
Valor cobrado{fmtBRL(os.valor)}
} +
+ {os.observacoes &&

{os.observacoes}

} +
+

Imprimir cupom de etapa

+
+ {FLUXO_OS.map(s => ( + + ))} +
+
)} -
- {os.material &&
Material{os.material}
} - {os.cor &&
Cor{os.cor}
} - {os.dentista_nome &&
Dentista{os.dentista_nome}
} - {modo === 'bancada' && Number(os.custo) > 0 &&
Custo lab{fmtBRL(os.custo)}
} - {modo === 'clinica' && Number(os.valor) > 0 &&
Valor cobrado{fmtBRL(os.valor)}
} -
- {os.observacoes &&

{os.observacoes}

} - - - - - {/* Histórico (auditoria) */} -
-

Histórico

-
- {os.eventos?.map((ev: any, i: number) => ( -
- -
- {ev.observacao || STATUS_LABEL[ev.status] || ev.status} - {dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''} + {tab === 'timeline' && ( +
+

Linha do tempo

+
+ {os.eventos?.map((ev: any, i: number) => ( +
+ +
+ {ev.observacao || STATUS_LABEL[ev.status] || ev.status} + {dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''} +
-
- ))} -
-
-
- - {/* Ações por papel */} - {!finalizado && ( -
- {modo === 'bancada' ? ( -
- {LAB_FLUXO.map(s => ( - ))}
- ) : ( - <> - {!os.fotos?.length &&

Anexe ao menos 1 foto antes de entregar (garantia)

} -
- - + {!finalizado && ( +
+ {modo === 'bancada' ? ( +
+ {LAB_FLUXO.map(s => ( + + ))} +
+ ) : ( + <> + {!os.fotos?.length &&

Anexe ao menos 1 foto (aba Itens) antes de entregar

} +
+ + +
+ + )}
- - )} -
- )} - {naGarantia && modo === 'clinica' && ( -
- -
- )} + )} + {naGarantia && modo === 'clinica' && ( + + )} +
+ )} + + {tab === 'itens' && ( +
+ + + +
+ )} + + {tab === 'monitor' && } +
+
+
+ ); +}; + +// ── 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 noLab = (os.local_atual || 'clinica') === 'laboratorio'; + return ( +
+

Onde está o trabalho

+
+
{noLab ? : }
+

{noLab ? 'Com o laboratório' : 'Na clínica'}

+

{noLab ? 'O protético está com o trabalho' : 'O trabalho está na clínica'}

+
+ {podeEditar && ( +
+ + +
+ )} +
+

Movimentações

+
+ {(os.custodia || []).map((c: any) => ( +
+ {c.de_local === 'clinica' ? 'Clínica' : 'Lab'} → {c.para_local === 'clinica' ? 'Clínica' : 'Lab'} + {c.actor_nome} · {dataHoraBR(c.created_at)} +
+ ))} + {(os.custodia || []).length === 0 &&

Sem movimentações registradas.

} +
);