From 8353a9d89778c7e732c0411adf0f7bec1971208f Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 25 Jun 2026 07:03:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20identifica=C3=A7=C3=A3o=20por?= =?UTF-8?q?=20Nome+Sobrenome=20com=20desambigua=C3=A7=C3=A3o=20de=20hom?= =?UTF-8?q?=C3=B4nimos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O protético identifica o trabalho pelo nome do paciente (etiqueta, sem CPF — ele não atende paciente). Quando dois trabalhos têm o mesmo nome+sobrenome, desambigua sem expor dado sensível: - util pacienteLabel.ts (rotulosPaciente / homonimoAtivo): comparação normalizada (case/acento/espaço-insensitive). Em colisão: 1) diferencia pela ORIGEM (dentista, senão clínica) → "João Silva · Dr. Carlos"; 2) se mesma origem, nº sequencial estável por data. - Bancada (ProteseView) e Painel do lab (LabHomeView/Próximas entregas): nome exibido já desambiguado em toda a fila — vale para OS nascidas na clínica ou no lab. - Modal "Dar entrada": campo único trocado por Nome + Sobrenome(s); aviso em tempo real quando há homônimo ativo ("acrescente outro sobrenome para diferenciar"), informando a origem do conflito. Validado em DEV (3 homônimos "João Silva": origem distinta e mesma origem → sequencial). Co-Authored-By: Claude Opus 4.8 --- frontend/utils/pacienteLabel.ts | 51 +++++++++++++++++++++++++++++++++ frontend/views/LabHomeView.tsx | 35 ++++++++++++++++------ frontend/views/ProteseView.tsx | 7 +++-- 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 frontend/utils/pacienteLabel.ts diff --git a/frontend/utils/pacienteLabel.ts b/frontend/utils/pacienteLabel.ts new file mode 100644 index 0000000..3596435 --- /dev/null +++ b/frontend/utils/pacienteLabel.ts @@ -0,0 +1,51 @@ +// Desambiguação de homônimos na bancada do protético. +// O protético identifica o trabalho pelo NOME do paciente (sem CPF — princípio de privacidade). +// Quando dois trabalhos têm o mesmo nome+sobrenome, diferenciamos sem expor dado sensível: +// 1) pela ORIGEM (dentista, senão clínica) — "João Silva · Dr. Carlos"; +// 2) se ainda empatar (mesma origem), por nº sequencial estável — "João Silva · Dr. Carlos (2)". +// Etiqueta livre, nunca um cadastro/atendimento de paciente. + +const DIACRITICOS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g'); +const norm = (s?: string) => + (s || '').normalize('NFD').replace(DIACRITICOS, '').toLowerCase().replace(/\s+/g, ' ').trim(); + +const origemDe = (o: any) => (o?.dentista_nome || o?.clinica_nome || o?.cliente_nome || '').trim(); + +// Recebe a lista de OS e devolve um Map id → rótulo de exibição já desambiguado. +export function rotulosPaciente(lista: any[]): Map { + const grupos = new Map(); + for (const o of lista || []) { + const k = norm(o?.paciente_nome); + if (!k) continue; + const g = grupos.get(k); + if (g) g.push(o); else grupos.set(k, [o]); + } + const out = new Map(); + for (const o of lista || []) { + const base = (o?.paciente_nome || '').trim() || 'Sem identificação'; + const k = norm(o?.paciente_nome); + const grupo = k ? (grupos.get(k) || [o]) : [o]; + if (grupo.length <= 1) { out.set(o.id, base); continue; } + const origem = origemDe(o); + const mesmaOrigem = grupo.filter(g => norm(origemDe(g)) === norm(origem)); + if (origem && mesmaOrigem.length === 1) { + out.set(o.id, `${base} · ${origem}`); + } else { + // mesma origem (ou origem ausente) → sequencial estável por data de criação + const ord = [...mesmaOrigem].sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || ''))); + const idx = ord.findIndex(g => g.id === o.id) + 1; + out.set(o.id, origem ? `${base} · ${origem} (${idx})` : `${base} (${idx})`); + } + } + return out; +} + +// Há outro trabalho ATIVO com o mesmo nome+sobrenome? (usado no aviso da entrada do lab) +// Retorna a origem do homônimo encontrado, ou null. +export function homonimoAtivo(nomeCompleto: string, lista: any[]): string | null { + const k = norm(nomeCompleto); + if (!k) return null; + const hit = (lista || []).find(o => + norm(o?.paciente_nome) === k && !['entregue', 'cancelado'].includes(o?.status)); + return hit ? (origemDe(hit) || 'outro cliente') : null; +} diff --git a/frontend/views/LabHomeView.tsx b/frontend/views/LabHomeView.tsx index 28c0106..a283c2d 100644 --- a/frontend/views/LabHomeView.tsx +++ b/frontend/views/LabHomeView.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { PageHeader } from '../components/PageHeader.tsx'; import { useToast } from '../contexts/ToastContext.tsx'; +import { rotulosPaciente, homonimoAtivo } from '../utils/pacienteLabel.ts'; const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); const CLI_TIPO = [ @@ -40,6 +41,7 @@ export const LabHomeView: React.FC = () => { catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); } }; + const rotuloPac = useMemo(() => rotulosPaciente(os), [os]); const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado'); const atrasadas = os.filter(o => o.atrasada).length; const ymMes = new Date().toISOString().slice(0, 7); @@ -148,7 +150,7 @@ export const LabHomeView: React.FC = () => { {ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => ( - {o.paciente_nome || '—'} + {rotuloPac.get(o.id) || o.paciente_nome || '—'} {o.tipo || '—'} {(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')} {STATUS_LABEL[o.status] || o.status} @@ -163,13 +165,13 @@ export const LabHomeView: React.FC = () => { )} - {showEntrada && setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />} + {showEntrada && setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />} ); }; // ── Modal: dar entrada em OS pelo lado do laboratório ──────────────────────── -const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { +const LabEntradaModal: React.FC<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => { const toast = useToast(); const [clientes, setClientes] = useState([]); const [produtos, setProdutos] = useState([]); @@ -182,9 +184,14 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = const [tipo, setTipo] = useState(''); const [custo, setCusto] = useState(''); const [prazo, setPrazo] = useState(''); - const [paciente, setPaciente] = useState(''); + const [nome, setNome] = useState(''); + const [sobrenome, setSobrenome] = useState(''); const [obs, setObs] = useState(''); + // Identificação do trabalho = Nome + Sobrenome (etiqueta, sem CPF). Avisa sobre homônimos ativos. + const pacienteNome = `${nome} ${sobrenome}`.replace(/\s+/g, ' ').trim(); + const homonimo = useMemo(() => pacienteNome ? homonimoAtivo(pacienteNome, osAtivas) : null, [pacienteNome, osAtivas]); + useEffect(() => { HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {}); HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {}); @@ -195,7 +202,7 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; } setBusy(true); try { - const body: any = { paciente_nome: paciente.trim() || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined }; + const body: any = { paciente_nome: pacienteNome || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined }; if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; } if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; } const r = await HybridBackend.criarLabOs(body); @@ -248,10 +255,20 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = )} -
-
setPrazo(e.target.value)} type="date" className={inp} />
-
setPaciente(e.target.value)} placeholder="Nome" className={inp} />
+ {/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */} +
+ +
+ setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} /> + setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} /> +
+ {homonimo && ( +

+ Já existe “{pacienteNome}” ativo ({homonimo}). Acrescente outro sobrenome para diferenciar. +

+ )}
+
setPrazo(e.target.value)} type="date" className={inp} />