From 9f103be39738beb575779008203e5fd7449bc1fb Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 02:32:21 +0200 Subject: [PATCH] feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar para a próxima"). 'entregue' segue na Linha do tempo. - Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico. - Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve — GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No front, o modo bancada esconde "Recebido do paciente". Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 72 +++++++++++++++++++++++-- frontend/services/backend.ts | 18 +++++++ frontend/views/ProteseView.tsx | 96 ++++++++++++++++++++++++++++++---- 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/backend/server.js b/backend/server.js index 22edc1a..d17d24d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3960,7 +3960,8 @@ app.get('/api/protese/bancada', authGuard, async (req, res) => { `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 }))); + // Bancada é a fila do laboratório: ele vê o próprio custo, não o valor cobrado ao paciente. + res.json(rows.map(r => ({ ...r, valor: 0, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada }))); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -3977,13 +3978,16 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { 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]); + // Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve. + const soLab = isLab && !isClinic; + const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos; res.json({ - ...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0), + ...os, valor: soLab ? 0 : 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) })), + pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })), custodia, }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -4107,6 +4111,68 @@ app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Editar campos do trabalho (cor, material, dentes, observações) — lab ou clínica. Auditado. +app.patch('/api/protese/os/:id', 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 }); + if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser editada.' }); + const b = req.body || {}; + const muda = []; + for (const campo of ['cor', 'material', 'dentes', 'observacoes']) { + if (b[campo] !== undefined) { + const novo = b[campo] === null || b[campo] === '' ? null : String(b[campo]); + if ((acc.os[campo] || null) !== novo) { await pool.query(`UPDATE protese_os SET ${campo}=$1 WHERE id=$2`, [novo, acc.os.id]); muda.push(`${campo}: ${acc.os[campo] || '—'} → ${novo || '—'}`); } + } + } + if (muda.length) await logEventoOS(acc.os, uid, `Trabalho editado (${muda.join('; ')})`, 'edicao'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Ajuste do valor cobrado do paciente (acréscimo/redução) — SOMENTE clínica. Auditado. +app.post('/api/protese/os/:id/ajuste-valor', 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 }); + if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica ajusta o valor do paciente.' }); + const delta = Number(req.body?.delta); + if (!delta || isNaN(delta)) return res.status(400).json({ error: 'Informe um valor de ajuste (+ ou −).' }); + const atual = parseFloat(acc.os.valor || 0); + const novo = Math.max(0, atual + delta); + await pool.query('UPDATE protese_os SET valor=$1 WHERE id=$2', [novo, acc.os.id]); + const sinal = delta >= 0 ? '+' : '−'; + const obs = req.body?.observacao ? ` · ${String(req.body.observacao).slice(0, 200)}` : ''; + await logEventoOS(acc.os, uid, `Valor ao paciente ${sinal}${Math.abs(delta).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}${obs} (novo total ${novo.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`, 'valor'); + res.json({ success: true, valor: novo }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Trocar o trabalho por outro (produto do catálogo do lab OU tipo livre) + motivo. Auditado. +app.post('/api/protese/os/:id/trocar', 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 }); + if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser trocada.' }); + const b = req.body || {}; + if (!b.motivo || !String(b.motivo).trim()) return res.status(400).json({ error: 'Informe o motivo da troca.' }); + const de = acc.os.tipo; + let novoTipo = b.tipo ? String(b.tipo).trim() : null, custo = parseFloat(acc.os.custo || 0), garantia = acc.os.garantia_meses, produtoId = acc.os.produto_id; + 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, acc.os.protetico_id]); + if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' }); + produtoId = pr[0].id; novoTipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantia = Number(pr[0].garantia_meses) || 12; + } + if (!novoTipo) return res.status(400).json({ error: 'Selecione o novo trabalho.' }); + await pool.query('UPDATE protese_os SET tipo=$1, produto_id=$2, custo=$3, garantia_meses=$4 WHERE id=$5', [novoTipo, produtoId, custo, garantia, acc.os.id]); + await logEventoOS(acc.os, uid, `Trabalho trocado: ${de} → ${novoTipo} · Motivo: ${String(b.motivo).trim()}`, 'troca'); + res.json({ success: true, tipo: novoTipo }); + } 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; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index c35a07f..1a44d55 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -819,6 +819,24 @@ export const HybridBackend = { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação'); return await res.json(); }, + // Editar campos do trabalho (cor, material, dentes, observações) + editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar'); + return await res.json(); + }, + // Ajuste do valor cobrado do paciente (delta + ou −) + ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor'); + return await res.json(); + }, + // Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo + trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho'); + 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 6fd87ec..0242731 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -318,24 +318,26 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{FLUXO_OS.map((s, i) => { const done = i < idxAtual, atual = i === idxAtual; - const podeAvancar = !finalizado && i === idxAtual + 1 && s !== 'entregue'; const ev = (os.eventos || []).find((e: any) => e.status === s); + // Etapas livres: pode ir para qualquer uma (pular/repetir/voltar), exceto 'entregue' (timeline). + const acaoLabel = atual ? 'Repetir' : done ? 'Voltar' : 'Ir'; return (
{done ? : atual ?
: } {STATUS_LABEL[s]} {ev ? dataBR(ev.created_at) : ''}
- {podeAvancar && } - {!finalizado && i === idxAtual + 1 && s === 'entregue' && aba Linha do tempo} + {!finalizado && s !== 'entregue' && } + {!finalizado && s === 'entregue' && aba Linha do tempo}
); })}
-

= térmica 80mm · A4 = laser

+

= térmica 80mm · A4 = laser · etapas podem ser repetidas/puladas

+ {!finalizado && }
)} @@ -383,6 +385,75 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose ); }; +// ── Seção: Ajustes do trabalho (cor/material, valor ao paciente, troca) ───── +const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: () => void }> = ({ os, modo, onChanged }) => { + const toast = useToast(); + const [cor, setCor] = useState(os.cor || ''); + const [material, setMaterial] = useState(os.material || ''); + const [delta, setDelta] = useState(''); + const [obsVal, setObsVal] = useState(''); + const [trocaOpen, setTrocaOpen] = useState(false); + const [novoTrab, setNovoTrab] = useState(''); + const [motivo, setMotivo] = useState(''); + const [busy, setBusy] = useState(false); + + const salvarCM = async () => { + setBusy(true); + try { await HybridBackend.editarProteseOS(os.id, { cor: cor.trim(), material: material.trim() }); toast.success('TRABALHO ATUALIZADO.'); onChanged(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const ajustar = async (sinal: number) => { + const v = parseFloat(delta); + if (!(v > 0)) { toast.error('INFORME O VALOR DO AJUSTE.'); return; } + setBusy(true); + try { await HybridBackend.ajustarValorProtese(os.id, sinal * v, obsVal.trim() || undefined); toast.success('VALOR DO PACIENTE AJUSTADO.'); setDelta(''); setObsVal(''); onChanged(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const trocar = async () => { + if (!novoTrab.trim() || !motivo.trim()) { toast.error('INFORME O NOVO TRABALHO E O MOTIVO.'); return; } + setBusy(true); + try { await HybridBackend.trocarTrabalhoProtese(os.id, { tipo: novoTrab.trim(), motivo: motivo.trim() }); toast.success('TRABALHO TROCADO.'); setTrocaOpen(false); setNovoTrab(''); setMotivo(''); onChanged(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + + return ( +
+

Ajustes do trabalho

+ {/* Cor / material */} +
+ setCor(e.target.value)} placeholder="Cor (ex.: A2)" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> + setMaterial(e.target.value)} placeholder="Material" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> +
+ + + {/* Ajuste de valor ao paciente — só clínica (protético não vê) */} + {modo === 'clinica' && ( +
+

Ajustar valor do paciente {Number(os.valor) > 0 ? `· atual ${fmtBRL(os.valor)}` : ''}

+
+ setDelta(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-24 p-2 bg-white border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" /> + setObsVal(e.target.value)} placeholder="Motivo (ex.: pino extra)" className="flex-1 p-2 bg-white border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> +
+
+ + +
+
+ )} + + {/* Trocar trabalho */} + + {trocaOpen && ( +
+ setNovoTrab(e.target.value)} placeholder="Novo trabalho (ex.: PPR metálica)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" /> + setMotivo(e.target.value)} placeholder="Motivo da troca (obrigatório)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" /> + +
+ )} +
+ ); +}; + // ── 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'; @@ -545,8 +616,9 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit return (

Valores recebidos

-
-

Recebido do paciente

{fmtBRL(totalClinica)}

+ {/* O protético (modo bancada) NÃO vê o que o paciente paga — só o que recebe do lab. */} +
+ {modo === 'clinica' &&

Recebido do paciente

{fmtBRL(totalClinica)}

}

Pago ao laboratório

{fmtBRL(totalLab)}

@@ -562,10 +634,14 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
{podeEditar && (
- + {modo === 'clinica' ? ( + + ) : ( + Lab + )} setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" /> setForma(e.target.value)} placeholder="Forma (pix...)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />