feat(protese): identificação por Nome+Sobrenome com desambiguação de homônimos

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-25 07:03:24 +02:00
parent 7c8c8fc0d2
commit 8353a9d897
3 changed files with 82 additions and 11 deletions
+51
View File
@@ -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<string, string> {
const grupos = new Map<string, any[]>();
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<string, string>();
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;
}
+26 -9
View File
@@ -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 { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx'; import { PageHeader } from '../components/PageHeader.tsx';
import { useToast } from '../contexts/ToastContext.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 fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const CLI_TIPO = [ const CLI_TIPO = [
@@ -40,6 +41,7 @@ export const LabHomeView: React.FC = () => {
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); } 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 ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
const atrasadas = os.filter(o => o.atrasada).length; const atrasadas = os.filter(o => o.atrasada).length;
const ymMes = new Date().toISOString().slice(0, 7); const ymMes = new Date().toISOString().slice(0, 7);
@@ -148,7 +150,7 @@ export const LabHomeView: React.FC = () => {
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => ( {ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}> <tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td> <td className="px-4 py-3 font-bold text-gray-700 uppercase">{rotuloPac.get(o.id) || o.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td> <td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td> <td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td> <td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
@@ -163,13 +165,13 @@ export const LabHomeView: React.FC = () => {
</> </>
)} )}
{showEntrada && <LabEntradaModal onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />} {showEntrada && <LabEntradaModal osAtivas={os} onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
</div> </div>
); );
}; };
// ── Modal: dar entrada em OS pelo lado do laboratório ──────────────────────── // ── 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 toast = useToast();
const [clientes, setClientes] = useState<any[]>([]); const [clientes, setClientes] = useState<any[]>([]);
const [produtos, setProdutos] = useState<any[]>([]); const [produtos, setProdutos] = useState<any[]>([]);
@@ -182,9 +184,14 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
const [tipo, setTipo] = useState(''); const [tipo, setTipo] = useState('');
const [custo, setCusto] = useState(''); const [custo, setCusto] = useState('');
const [prazo, setPrazo] = useState(''); const [prazo, setPrazo] = useState('');
const [paciente, setPaciente] = useState(''); const [nome, setNome] = useState('');
const [sobrenome, setSobrenome] = useState('');
const [obs, setObs] = 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(() => { useEffect(() => {
HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {}); HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {});
HybridBackend.getProteseProdutos().then(r => setProdutos(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; } if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
setBusy(true); setBusy(true);
try { 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 (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; } if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; }
const r = await HybridBackend.criarLabOs(body); const r = await HybridBackend.criarLabOs(body);
@@ -248,10 +255,20 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
</div> </div>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3"> {/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div> <div>
<div><label className={lbl}>Paciente (opcional)</label><input value={paciente} onChange={e => setPaciente(e.target.value)} placeholder="Nome" className={inp} /></div> <label className={lbl}>Identificação do trabalho (paciente)</label>
<div className="grid grid-cols-2 gap-3 mt-1">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} />
<input value={sobrenome} onChange={e => setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} />
</div>
{homonimo && (
<p className="mt-1.5 text-[10px] font-bold text-amber-600 uppercase flex items-start gap-1">
<AlertTriangle size={12} className="shrink-0 mt-px" /> existe {pacienteNome} ativo ({homonimo}). Acrescente outro sobrenome para diferenciar.
</p>
)}
</div> </div>
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
<div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div> <div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div>
<button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button> <button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button>
<p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra com o laboratório (custódia) e no seu financeiro.</p> <p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra com o laboratório (custódia) e no seu financeiro.</p>
+5 -2
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from '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, Share2, FileText, Star } 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, Share2, FileText, Star } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx'; import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx'; import { PageHeader } from '../components/PageHeader.tsx';
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/'); const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
@@ -1102,6 +1103,8 @@ export const ProteseView: React.FC = () => {
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const [lista, setLista] = useState<any[]>([]); const [lista, setLista] = useState<any[]>([]);
// Rótulos desambiguados de homônimos (só faz sentido na bancada, onde o protético lê o nome sem CPF).
const rotuloPac = useMemo(() => modo === 'bancada' ? rotulosPaciente(lista) : new Map<string, string>(), [lista, modo]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filtro, setFiltro] = useState(''); const [filtro, setFiltro] = useState('');
const [novaOS, setNovaOS] = useState(false); const [novaOS, setNovaOS] = useState(false);
@@ -1175,7 +1178,7 @@ export const ProteseView: React.FC = () => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p> <p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p>
<p className="text-xs text-gray-400 truncate"> <p className="text-xs text-gray-400 truncate">
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')} {modo === 'bancada' ? (rotuloPac.get(os.id) || os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''} {os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
</p> </p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */} {/* Clínica de origem: essencial quando o lab atende várias clínicas. */}