8353a9d897
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>
280 lines
18 KiB
TypeScript
280 lines
18 KiB
TypeScript
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 = [
|
||
{ v: 'clinica', label: 'Clínica', icon: Building2 },
|
||
{ v: 'consultorio', label: 'Consultório', icon: Briefcase },
|
||
{ v: 'dentista', label: 'Dentista', icon: Stethoscope },
|
||
];
|
||
|
||
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
|
||
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
|
||
export const LabHomeView: React.FC = () => {
|
||
const ws: any = HybridBackend.getActiveWorkspace();
|
||
const toast = useToast();
|
||
const [os, setOs] = useState<any[]>([]);
|
||
const [fin, setFin] = useState<any[]>([]);
|
||
const [indicadas, setIndicadas] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showEntrada, setShowEntrada] = useState(false);
|
||
const [importando, setImportando] = useState<string | null>(null);
|
||
|
||
const carregar = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]);
|
||
setOs(Array.isArray(b) ? b : []);
|
||
setFin(Array.isArray(f) ? f : []);
|
||
setIndicadas(Array.isArray(ind) ? ind : []);
|
||
} catch { /* silent */ } finally { setLoading(false); }
|
||
}, []);
|
||
useEffect(() => { carregar(); }, [carregar]);
|
||
|
||
const importar = async (osId: string) => {
|
||
setImportando(osId);
|
||
try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); }
|
||
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);
|
||
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
|
||
// Faturamento do mês: do fechamento do próprio lab (custo das OS entregues no mês).
|
||
const fatMes = Number(fin.find(m => m.mes === ymMes)?.faturado || 0);
|
||
|
||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||
</div>
|
||
);
|
||
|
||
// Distribuição por status (fila de produção).
|
||
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
|
||
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
|
||
|
||
return (
|
||
<div className="space-y-6 pb-10">
|
||
<PageHeader title="PAINEL DO LABORATÓRIO">
|
||
<button onClick={() => setShowEntrada(true)} className="text-white px-3 py-2 rounded-lg flex items-center gap-2 text-xs font-black uppercase shadow-sm" style={{ backgroundColor: '#2d6a4f' }}>
|
||
<Plus size={16} /> <span className="hidden sm:inline">Dar entrada</span>
|
||
</button>
|
||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||
</button>
|
||
</PageHeader>
|
||
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
|
||
<div className="min-w-0">
|
||
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
|
||
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||
<>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
|
||
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
|
||
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
|
||
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
|
||
</div>
|
||
|
||
{/* OS indicadas ao laboratório aguardando importação */}
|
||
{indicadas.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-amber-100 bg-amber-50/60 flex items-center gap-2">
|
||
<ArrowDownToLine size={16} className="text-amber-600" />
|
||
<h3 className="text-sm font-black text-amber-800 uppercase">OS indicadas a você ({indicadas.length})</h3>
|
||
<span className="text-[10px] font-bold text-amber-500 uppercase ml-auto hidden sm:inline">Importe para entrar na produção e financeiro</span>
|
||
</div>
|
||
<div className="divide-y divide-gray-50">
|
||
{indicadas.map(o => (
|
||
<div key={o.id} className="px-6 py-3 flex items-center gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-black text-gray-800 uppercase truncate text-sm">{o.tipo}</p>
|
||
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}</p>
|
||
</div>
|
||
<span className="text-sm font-black text-gray-700 hidden sm:block">{fmtBRL(o.custo)}</span>
|
||
<button disabled={importando === o.id} onClick={() => importar(o.id)} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5 shrink-0">
|
||
<ArrowDownToLine size={13} /> Importar
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Fila por status */}
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||
<Clock size={16} className="text-teal-600" />
|
||
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
|
||
</div>
|
||
{porStatus.length === 0 ? (
|
||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
|
||
) : (
|
||
<div className="p-4 flex flex-wrap gap-3">
|
||
{porStatus.map(({ s, n }) => (
|
||
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
|
||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
|
||
<p className="text-2xl font-black text-gray-800">{n}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Próximas entregas */}
|
||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
||
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
|
||
</div>
|
||
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
|
||
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-xs">
|
||
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||
<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">{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>
|
||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{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<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
|
||
const toast = useToast();
|
||
const [clientes, setClientes] = useState<any[]>([]);
|
||
const [produtos, setProdutos] = useState<any[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
// origem do cliente: existente ou avulso
|
||
const [clienteId, setClienteId] = useState(''); // '' = avulso
|
||
const [cliNome, setCliNome] = useState('');
|
||
const [cliTipo, setCliTipo] = useState('clinica');
|
||
const [produtoId, setProdutoId] = useState(''); // '' = tipo livre
|
||
const [tipo, setTipo] = useState('');
|
||
const [custo, setCusto] = useState('');
|
||
const [prazo, setPrazo] = 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(() => {});
|
||
}, []);
|
||
|
||
const salvar = async () => {
|
||
if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; }
|
||
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
|
||
setBusy(true);
|
||
try {
|
||
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);
|
||
if (r?.error) throw new Error(r.error);
|
||
toast.success('ENTRADA REGISTRADA.');
|
||
onSaved();
|
||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||
};
|
||
|
||
const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide';
|
||
const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400';
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> Dar entrada em OS</h3>
|
||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||
</div>
|
||
<div className="p-5 space-y-4">
|
||
{/* Cliente */}
|
||
<div>
|
||
<label className={lbl}>Cliente</label>
|
||
<select value={clienteId} onChange={e => setClienteId(e.target.value)} className={inp}>
|
||
<option value="">➕ Novo cliente avulso…</option>
|
||
{clientes.map(c => <option key={c.clinica_id} value={c.clinica_id}>{c.clinica_nome} ({c.origem})</option>)}
|
||
</select>
|
||
{!clienteId && (
|
||
<div className="mt-2 space-y-2 bg-gray-50 rounded-xl p-3">
|
||
<input value={cliNome} onChange={e => setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} />
|
||
<div className="flex gap-2">
|
||
{CLI_TIPO.map(t => { const I = t.icon; return (
|
||
<button key={t.v} onClick={() => setCliTipo(t.v)} className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1 border ${cliTipo === t.v ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-200 text-gray-400'}`}><I size={12} /> {t.label}</button>
|
||
); })}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Produto */}
|
||
<div>
|
||
<label className={lbl}>Produto</label>
|
||
<select value={produtoId} onChange={e => setProdutoId(e.target.value)} className={inp}>
|
||
<option value="">✏️ Tipo livre…</option>
|
||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(Number(p.preco || 0))}</option>)}
|
||
</select>
|
||
{!produtoId && (
|
||
<div className="mt-2 flex gap-2">
|
||
<input value={tipo} onChange={e => setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} />
|
||
<input value={custo} onChange={e => setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} />
|
||
</div>
|
||
)}
|
||
</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" /> Já 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 já com o laboratório (custódia) e no seu financeiro.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|