Files
scoreodonto.com/frontend/views/LabHomeView.tsx
T
VPS 4 Builder 7c8c8fc0d2
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
feat(protese): fluxo do lado do protético — entrada de OS, baixa/cobrança e importar OS indicada
Fecha o ciclo operacional do laboratório, antes só "receptor passivo" das OS da clínica.

1. Dar entrada em OS pelo lab — POST /api/protese/lab/os: o dono do laboratório registra um
   trabalho recebido fora do app, escolhendo cliente existente OU avulso (clínica/consultório/
   dentista, materializado como registro mínimo) + produto do catálogo ou tipo livre. A OS já
   entra com laboratorio_id e custódia no laboratório. UI: botão "Dar entrada" + modal na LabHome.
2. Dar baixa em pagamento — já existia (lado 'lab'); mantido na PagamentosSecao.
3. Solicitar pagamento — POST /api/protese/os/:id/cobrar: calcula o saldo devedor (custo − pago)
   e notifica o cliente, registrando no histórico. UI: botão "Solicitar pagamento (saldo)" na
   PagamentosSecao do modo bancada.
4. Importar/vincular OS indicada — GET /api/protese/lab/os-indicadas (OS com protetico_id=me e
   laboratorio_id NULL) + POST /api/protese/os/:id/vincular-lab (seta laboratorio_id, status→recebido,
   notifica a clínica). Sem isso a OS só aparecia ao dono via fallback; importando, a equipe/técnicos
   e o financeiro do lab passam a vê-la. UI: seção "OS indicadas a você" na LabHome.

origemDe agora reconhece cliente avulso tipo 'dentista'. Validado em DEV (todos os 4 endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:43:35 +02:00

263 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } 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';
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 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">{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 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 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 [paciente, setPaciente] = useState('');
const [obs, setObs] = useState('');
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: paciente.trim() || 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>
<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>
</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>
</div>
</div>
</div>
);
};