Compare commits

..

3 Commits

Author SHA1 Message Date
VPS 4 Builder d83e0c82b2 feat(ui): dropdowns viram modal com busca (SearchSelect) na Avaliação/GTO e Nova OS
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Mesmo padrão do seletor de protético, generalizado para os demais dropdowns:

- Componente SearchSelect (modal genérico: trigger + busca + lista filtrável por rótulo/hint).
- LancarGTO: Especialidade/Área, Produto/Prótese, Procedimento, Dentista e Convênio/Credenciada
  — todos os <select> convertidos (0 selects restantes).
- Nova OS (ProteseView): Produto e Dentista convertidos.
- Cotação (RFQ): input de busca na lista de laboratórios a convidar (continua multi-seleção).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:29:49 +02:00
VPS 4 Builder e6d8b532e3 feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)
O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com
conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado:

- Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO
  (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um
  protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente.
- Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios
  passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e
  POST /protese/laboratorios/vincular.
- Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético
  SEM conta) mantido ao lado.

Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e,
como favorito+padrão, no topo da lista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:18:32 +02:00
VPS 4 Builder 8353a9d897 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>
2026-06-25 07:03:24 +02:00
8 changed files with 398 additions and 88 deletions
+60 -8
View File
@@ -3839,23 +3839,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
const { rows } = await pool.query(
`SELECT u.id, u.nome, u.email,
BOOL_OR(v.clinica_id = $1) AS interno
`SELECT u.id, u.nome, u.email, u.celular,
BOOL_OR(v.clinica_id = $1) AS interno,
BOOL_OR(pf.favorito) AS favorito,
BOOL_OR(pf.padrao) AS padrao
FROM usuarios u
LEFT JOIN vinculos v ON v.usuario_id = u.id
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
WHERE u.role = 'protetico'
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
GROUP BY u.id, u.nome, u.email
ORDER BY interno DESC, u.nome`, [clinicaId]);
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
GROUP BY u.id, u.nome, u.email, u.celular
ORDER BY u.nome`, [clinicaId]);
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
const notas = await notasScorePorProtetico(rows.map(r => r.id));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: internos primeiro, depois maior nota, depois nome.
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: padrão → favorito → interno → maior nota nome.
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
res.json(lista);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const protId = req.params.proteticoId;
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
const favorito = req.body?.favorito;
const padrao = req.body?.padrao;
// Só pode haver UM padrão por clínica.
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
await pool.query(
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
VALUES ($1,$2,$3,$4)
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const email = (req.body?.email || '').toLowerCase().trim();
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
const p = rows[0];
await pool.query(
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
@@ -6858,6 +6901,15 @@ async function runMigrations() {
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
clinica_id TEXT NOT NULL,
protetico_id TEXT NOT NULL,
favorito BOOLEAN DEFAULT false,
padrao BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (clinica_id, protetico_id)
)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [labs, setLabs] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [addEmail, setAddEmail] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const preselected = useRef(false);
const carregar = useCallback(async (): Promise<any[]> => {
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
catch { setLabs([]); return []; }
}, []);
useEffect(() => { carregar(); }, [carregar]);
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
useEffect(() => {
if (preselected.current || value) return;
const pad = labs.find(l => l.padrao);
if (pad) { preselected.current = true; onChange(pad.id, pad); }
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = labs.find(l => l.id === value);
const q = busca.toLowerCase();
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
const toggleFav = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
catch { toast.error('ERRO'); }
};
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
catch { toast.error('ERRO'); }
};
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
const adicionar = async () => {
if (!addEmail.trim()) return;
setBusy(true);
try {
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
if (r?.error) throw new Error(r.error);
toast.success('PROTÉTICO VINCULADO.');
setAddEmail(''); setShowAdd(false);
const novos = await carregar();
const novo = novos.find((x: any) => x.id === r.protetico?.id);
if (novo) selecionar(novo);
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<>
<button type="button" onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
<span className="truncate flex items-center gap-2">
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
</span>
<Search size={16} className="text-gray-400 shrink-0" />
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-4 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
) : filtrados.map(l => (
<div key={l.id} onClick={() => selecionar(l)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
</button>
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
{l.nota != null && <span>· {l.nota}</span>}
{l.padrao && <span className="text-violet-600">· Padrão</span>}
</p>
</div>
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
</div>
))}
</div>
<div className="p-4 border-t border-gray-100">
{showAdd ? (
<div className="flex gap-2">
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
</button>
)}
</div>
</div>
</div>
)}
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+12
View File
@@ -748,6 +748,18 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
return await res.json();
},
// Favorito (★) / padrão do protético na clínica ativa.
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Vincular um protético existente (com conta) como interno, por e-mail.
vincularProteticoEmail: async (email: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
return await res.json().catch(() => ({ error: 'falha' }));
},
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const qs = new URLSearchParams({ clinicaId });
+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 { 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 = () => {
<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 => (
<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 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>
@@ -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>
);
};
// ── 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<any[]>([]);
const [produtos, setProdutos] = useState<any[]>([]);
@@ -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 }> =
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
<div><label className={lbl}>Paciente (opcional)</label><input value={paciente} onChange={e => setPaciente(e.target.value)} placeholder="Nome" className={inp} /></div>
{/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
<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><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>
<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>
+32 -53
View File
@@ -13,6 +13,8 @@ import {
X
} from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
import { useToast } from '../contexts/ToastContext.tsx';
@@ -123,7 +125,6 @@ export const LancarGTO: React.FC = () => {
const [patientSearchTerm, setPatientSearchTerm] = useState('');
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
const [laboratorios, setLaboratorios] = useState<any[]>([]);
const [selectedLab, setSelectedLab] = useState<string>('');
const [labProdutos, setLabProdutos] = useState<any[]>([]);
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
@@ -162,12 +163,6 @@ export const LancarGTO: React.FC = () => {
[labProdutos, selectedProdutoId]
);
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
useEffect(() => {
if (!isProtese || laboratorios.length) return;
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
}, [isProtese, laboratorios.length]);
// Catálogo do laboratório selecionado.
useEffect(() => {
if (!selectedLab) { setLabProdutos([]); return; }
@@ -463,10 +458,10 @@ export const LancarGTO: React.FC = () => {
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
</div>
<div className="space-y-3">
<select
<SearchSelect
value={selectedEsp}
onChange={(e) => {
setSelectedEsp(e.target.value);
onChange={(v) => {
setSelectedEsp(v);
setSelectedProc(null);
setSelectedDentes([]);
setSelectedArco(null);
@@ -474,22 +469,13 @@ export const LancarGTO: React.FC = () => {
setProtValor('');
setProtObs('');
}}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
>
<option value="">ESCOLHA A ÁREA...</option>
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
</select>
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
/>
{selectedEsp && isProtese && (
<div className="space-y-3">
<select
value={selectedLab}
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
>
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
{selectedLab && (labProdutos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
@@ -497,18 +483,17 @@ export const LancarGTO: React.FC = () => {
</div>
) : (
<>
<select
<SearchSelect
value={selectedProdutoId}
onChange={(e) => {
setSelectedProdutoId(e.target.value);
const pr = labProdutos.find(p => p.id === e.target.value);
onChange={(v) => {
setSelectedProdutoId(v);
const pr = labProdutos.find(p => p.id === v);
setProtValor(pr ? String(pr.preco ?? '') : '');
}}
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">ESCOLHA A PRÓTESE...</option>
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} R$ {Number(p.preco || 0).toFixed(2)}</option>)}
</select>
/>
{selectedProduto && (
<>
@@ -556,21 +541,20 @@ export const LancarGTO: React.FC = () => {
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
</div>
) : (
<select
<SearchSelect
value={selectedProc?.id || ''}
onChange={(e) => {
const pr = procedimentos.find(p => p.id === e.target.value);
onChange={(v) => {
const pr = procedimentos.find(p => p.id === v);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">BUSCAR PROCEDIMENTO</option>
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select>
/>
)}
{selectedProc && (
@@ -601,28 +585,23 @@ export const LancarGTO: React.FC = () => {
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
</div>
<select
<SearchSelect
value={store.dentista?.id || ''}
onChange={(e) => {
const d = dentistas.find(d => d.id === e.target.value);
store.setDentista(d || null);
}}
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O PROFISSIONAL</option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
/>
<div className="pt-1">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
<select
<SearchSelect
value={store.credenciada || ''}
onChange={(e) => store.setCredenciada(e.target.value)}
onChange={(v) => store.setCredenciada(v)}
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O CONVÊNIO</option>
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
/>
</div>
</div>
);
+24 -18
View File
@@ -1,9 +1,12 @@
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 { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
@@ -62,7 +65,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const toast = useToast();
const [labs, setLabs] = useState<any[]>([]);
const [pickerKey, setPickerKey] = useState(0);
const [dentistas, setDentistas] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [pacientes, setPacientes] = useState<any[]>([]);
@@ -73,7 +76,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
const ws = HybridBackend.getActiveWorkspace();
useEffect(() => {
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
}, [ws?.id]);
@@ -151,20 +153,17 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
{/* Laboratório */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Selecione...</option>
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
</select>
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
</div>
{/* Produto (catálogo do laboratório — preço fixo) */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
{form.protetico_id && produtos.length > 0 ? (
<select value={form.produto_id} onChange={e => set('produto_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Selecione...</option>
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} {fmtBRL(p.preco)}</option>)}
</select>
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
placeholder="Selecione..." title="Produto do laboratório"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
@@ -176,10 +175,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
{/* Dentista */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value=""></option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
<div className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="—" title="Dentista"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
{/* Material + cor + dentes */}
<div className="grid grid-cols-3 gap-3">
@@ -1010,6 +1009,7 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
const [dentes, setDentes] = useState('');
const [prazo, setPrazo] = useState('');
const [sel, setSel] = useState<string[]>([]);
const [buscaLab, setBuscaLab] = useState('');
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
@@ -1052,8 +1052,12 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{labs.map(l => (
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).map(l => (
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
@@ -1102,6 +1106,8 @@ export const ProteseView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
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 [filtro, setFiltro] = useState('');
const [novaOS, setNovaOS] = useState(false);
@@ -1175,7 +1181,7 @@ export const ProteseView: React.FC = () => {
<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="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)}` : ''}
</p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}