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>
This commit is contained in:
@@ -1099,6 +1099,25 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
// Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório.
|
||||
getLabOsIndicadas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
vincularOsLab: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: solicitar pagamento (cobrar saldo) ao cliente.
|
||||
cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso).
|
||||
criarLabOs: async (body: any) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
||||
getLabDados: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`);
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } 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 { 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] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento()]);
|
||||
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);
|
||||
@@ -47,6 +64,9 @@ export const LabHomeView: React.FC = () => {
|
||||
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>
|
||||
@@ -69,6 +89,31 @@ export const LabHomeView: React.FC = () => {
|
||||
<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">
|
||||
@@ -117,6 +162,101 @@ export const LabHomeView: React.FC = () => {
|
||||
</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 já com o laboratório (custódia) e no seu financeiro.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -875,6 +875,13 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProtesePagamento(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
// Solicitar pagamento (cobrança) do saldo devedor — apenas o laboratório.
|
||||
const saldoLab = Math.max(0, Number(os.custo || 0) - totalLab);
|
||||
const cobrar = async () => {
|
||||
setBusy(true);
|
||||
try { const r = await HybridBackend.cobrarOs(os.id); if (r?.error) throw new Error(r.error); toast.success('SOLICITAÇÃO ENVIADA AO CLIENTE.'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
|
||||
@@ -909,6 +916,12 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
{/* Solicitar pagamento (cobrança) — só o laboratório, quando há saldo devedor. */}
|
||||
{modo === 'bancada' && podeEditar && saldoLab > 0 && (
|
||||
<button disabled={busy} onClick={cobrar} className="mt-2 w-full py-2 rounded-lg border border-amber-300 bg-amber-50 text-amber-700 text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-1.5">
|
||||
<Wallet size={13} /> Solicitar pagamento ({fmtBRL(saldoLab)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user