Files
scoreodonto.com/frontend/views/ProteseView.tsx
T
VPS 4 Builder 84f4a797d2
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
feat(protese): Fase 5 — indicadores + Nota ScoreOdonto + ranking no marketplace
Provedor de Prótese, Fase 5 (doc/PROVEDOR-PROTESE-DECISAO.md) — sobre a captura das fases 2-4:

- Indicadores do lab (GET /protese/lab/indicadores, só dono): entregues, tempo médio, atraso %,
  retrabalho % (garantia), ocorrências resolvidas %, componentes ausentes. Tela lab-indicadores.
- Nota ScoreOdonto (0-10) = 10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3), só com ≥3 entregas
  ("score em formação" abaixo disso). Apenas ocorrências RESOLVIDAS pesam (contraditório respeitado).
- Ranking: getProteseLaboratorios devolve a nota por protético e ordena internos→nota→nome; a
  clínica vê ★ nota no dropdown de Nova OS e Lançar GTO.

Roadmap do Provedor de Prótese concluído (Fases 1-5). Marketplace transacional e Modo 1 (link
seguro sem conta) ficam como evolução futura.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:49:32 +02:00

945 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2 } 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';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const dataHoraBR = (d: string) => {
if (!d) return '';
const dt = new Date(d);
return isNaN(dt.getTime()) ? dataBR(d) : dt.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const STATUS_LABEL: Record<string, string> = {
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado',
};
const STATUS_COR: Record<string, string> = {
solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700',
prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700',
entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600',
};
const Badge: React.FC<{ s: string }> = ({ s }) => (
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
);
// ── 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 [dentistas, setDentistas] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [pacientes, setPacientes] = useState<any[]>([]);
const [paciente, setPaciente] = useState<any>(null);
const [produtos, setProdutos] = useState<any[]>([]);
const [form, setForm] = useState<any>({ protetico_id: '', produto_id: '', tipo: '', dentista_id: '', material: '', cor: '', dentes: '', prazo_entrega: '', valor: '', observacoes: '' });
const [saving, setSaving] = useState(false);
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]);
// Catálogo (preço fixo) do laboratório escolhido.
useEffect(() => {
if (!form.protetico_id) { setProdutos([]); return; }
HybridBackend.getLaboratorioProdutos(form.protetico_id)
.then(r => setProdutos(Array.isArray(r) ? r : []))
.catch(() => setProdutos([]));
setForm((f: any) => ({ ...f, produto_id: '', tipo: '' }));
}, [form.protetico_id]);
const produtoSel = produtos.find(p => p.id === form.produto_id);
useEffect(() => {
if (paciente) return;
const t = setTimeout(() => {
HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([]));
}, 250);
return () => clearTimeout(t);
}, [busca, paciente]);
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
const salvar = async () => {
if (!form.protetico_id) { toast.error('SELECIONE O LABORATÓRIO.'); return; }
if (!form.produto_id && !form.tipo) { toast.error('SELECIONE O PRODUTO.'); return; }
const dent = dentistas.find(d => d.id === form.dentista_id);
setSaving(true);
try {
await HybridBackend.createProteseOS({
...form,
valor: Number(form.valor) || 0,
paciente_id: paciente?.id || null,
paciente_nome: paciente?.nome || null,
dentista_nome: dent?.nome || null,
});
toast.success('OS DE PRÓTESE CRIADA.');
onSaved();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setSaving(false); }
};
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-lg max-h-[90vh] 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">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> Nova OS de Prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-5 space-y-4">
{/* Paciente */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
{paciente ? (
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1">
<span className="text-sm font-bold text-gray-700">{paciente.nome}</span>
<button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button>
</div>
) : (
<div className="relative mt-1">
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..."
className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
{busca && pacientes.length > 0 && (
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
{pacientes.slice(0, 8).map(p => (
<button key={p.id} onClick={() => { setPaciente(p); }} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>
))}
</div>
)}
</div>
)}
</div>
{/* 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>
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
</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>
) : (
<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" />
)}
{produtoSel && (
<p className="text-[10px] text-gray-500 mt-1">Custo de laboratório <b>{fmtBRL(produtoSel.preco)}</b> · garantia <b>{produtoSel.garantia_meses} meses</b></p>
)}
</div>
{/* 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>
{/* Material + cor + dentes */}
<div className="grid grid-cols-3 gap-3">
<div><label className="text-[10px] font-black text-gray-500 uppercase">Material</label><input value={form.material} onChange={e => set('material', e.target.value)} placeholder="Zircônia..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
<div><label className="text-[10px] font-black text-gray-500 uppercase">Cor</label><input value={form.cor} onChange={e => set('cor', e.target.value)} placeholder="A2..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
<div><label className="text-[10px] font-black text-gray-500 uppercase">Dentes</label><input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="11,21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
{/* Prazo + valor cobrado do paciente */}
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[10px] font-black text-gray-500 uppercase">Prazo de entrega</label><input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
<div><label className="text-[10px] font-black text-gray-500 uppercase">Valor ao paciente R$</label><input type="number" value={form.valor} onChange={e => set('valor', e.target.value)} placeholder="0,00" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Observações</label>
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
</div>
</div>
<div className="p-5 border-t border-gray-100 flex gap-3 sticky bottom-0 bg-white">
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-gray-200 font-black text-gray-500 text-sm uppercase">Cancelar</button>
<button onClick={salvar} disabled={saving} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-sm uppercase disabled:opacity-50">{saving ? 'Salvando...' : 'Criar OS'}</button>
</div>
</div>
</div>
);
};
// ── Modal: Detalhe da OS (timeline + fotos + ações por papel) ───────────────
const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose: () => void; onChanged: () => void }> = ({ id, modo, onClose, onChanged }) => {
const toast = useToast();
const confirm = useConfirm();
const [os, setOs] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas');
const carregar = useCallback(() => {
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
}, [id, toast]);
useEffect(() => { carregar(); }, [carregar]);
const mudar = async (status: string) => {
if (status === 'cancelado' && !(await confirm('CANCELAR ESTA OS?'))) return;
setBusy(true);
try { await HybridBackend.setProteseOSStatus(id, status); toast.success(`OS: ${(STATUS_LABEL[status] || status).toUpperCase()}.`); carregar(); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setBusy(false); }
};
const acionarGarantia = async () => {
if (!(await confirm('ABRIR UMA OS DE GARANTIA (SEM CUSTO) PARA REFAZER ESTE TRABALHO?'))) return;
setBusy(true);
try {
await HybridBackend.createProteseOS({
protetico_id: os.protetico_id, produto_id: os.produto_id || null, tipo: os.tipo,
tipo_os: 'garantia', origem_os_id: os.id,
paciente_id: os.paciente_id, paciente_nome: os.paciente_nome,
dentista_id: os.dentista_id, dentista_nome: os.dentista_nome,
material: os.material, cor: os.cor, dentes: os.dentes,
observacoes: `Garantia da OS ${os.id}`,
});
toast.success('OS DE GARANTIA ABERTA (SEM CUSTO).');
onChanged(); onClose();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setBusy(false); }
};
const moverCustodia = async (para: 'clinica' | 'laboratorio') => {
setBusy(true);
try { await HybridBackend.moverProteseCustodia(id, para); toast.success(para === 'laboratorio' ? 'TRABALHO COM O LABORATÓRIO.' : 'TRABALHO NA CLÍNICA.'); carregar(); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setBusy(false); }
};
if (!os) return null;
const finalizado = os.status === 'entregue' || os.status === 'cancelado';
const naGarantia = os.status === 'entregue' && os.garantia_ate && os.garantia_ate >= new Date().toISOString().slice(0, 10);
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
// Recibo imprimível de uma etapa (comprovante que circula com o trabalho).
// formato 'termica' (bobina 80mm) | 'laser' (folha A4). Dados preenchidos da OS.
const imprimirCupom = (etapa: string, formato: 'termica' | 'laser') => {
const comps = (os.componentes || []).map((c: any) => `${c.quantidade}× ${c.nome}`).join(', ') || '—';
const termica = formato === 'termica';
const page = termica ? '@page{size:80mm auto;margin:3mm}body{width:72mm;font-size:11px}'
: '@page{size:A4;margin:16mm}body{max-width:520px;font-size:12px}';
const sign = termica
? '.sign{margin-top:24px}.sign div{border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555;margin-top:18px}'
: '.sign{margin-top:34px;display:flex;justify-content:space-between;gap:16px}.sign div{flex:1;border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555}';
const w = window.open('', '_blank', termica ? 'width=320,height=640' : 'width=560,height=720'); if (!w) return;
w.document.write(`<html><head><title>Recibo ${STATUS_LABEL[etapa] || etapa}</title><style>
*{font-family:Arial,Helvetica,sans-serif;color:#111;box-sizing:border-box}body{margin:0;padding:10px}
h1{font-size:${termica ? 13 : 16}px;margin:0 0 2px}.muted{color:#555;font-size:${termica ? 9 : 10}px}
.et{background:#111;color:#fff;padding:6px 8px;border-radius:5px;font-weight:bold;text-align:center;text-transform:uppercase;margin:10px 0;font-size:${termica ? 11 : 13}px}
table{width:100%;border-collapse:collapse;margin-top:6px}td{padding:3px 0;vertical-align:top}
.lbl{color:#555;text-transform:uppercase;font-size:9px;width:40%}
.hr{border-top:1px dashed #999;margin:9px 0}${sign}${page}</style></head><body>
<h1>ORDEM DE PRÓTESE</h1><div class="muted">OS ${os.id}<br>${new Date().toLocaleString('pt-BR')}</div>
<div class="et">Etapa: ${STATUS_LABEL[etapa] || etapa}</div>
<table>
<tr><td class="lbl">Paciente</td><td><b>${os.paciente_nome || '—'}</b></td></tr>
<tr><td class="lbl">Trabalho</td><td>${os.tipo || '—'}</td></tr>
<tr><td class="lbl">Dentista</td><td>${os.dentista_nome || '—'}</td></tr>
<tr><td class="lbl">Laboratório</td><td>${os.protetico_nome || '—'}</td></tr>
<tr><td class="lbl">Dentes</td><td>${os.dentes || '—'}</td></tr>
<tr><td class="lbl">Material / Cor</td><td>${os.material || '—'} / ${os.cor || '—'}</td></tr>
<tr><td class="lbl">Componentes</td><td>${comps}</td></tr>
<tr><td class="lbl">Prazo</td><td>${os.prazo_entrega ? dataBR(os.prazo_entrega) : '—'}</td></tr>
<tr><td class="lbl">Local atual</td><td>${noLab ? 'Laboratório' : 'Clínica'}</td></tr>
</table><div class="hr"></div>
${os.observacoes ? `<div class="muted">Obs.: ${os.observacoes}</div>` : ''}
<div class="sign"><div>Entregue por</div><div>Recebido por</div></div>
</body></html>`);
w.document.close(); w.focus(); setTimeout(() => w.print(), 250);
};
const idxAtual = FLUXO_OS.indexOf(os.status);
const TABS = [
{ id: 'etapas', label: 'Etapas' },
{ id: 'itens', label: 'Itens & Valores' },
{ id: 'monitor', label: 'Monitoramento' },
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
{ id: 'timeline', label: 'Linha do tempo' },
] as const;
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-2xl h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="min-w-0">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
<p className="text-xs text-gray-400 mt-0.5 truncate">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge s={os.status} />
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
</div>
{/* Abas */}
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-5">
{tab === 'etapas' && (
<div className="space-y-5">
{/* Clínica de origem — crítico no multi-clínica (de qual clínica veio a OS). */}
{modo === 'bancada' && os.clinica_nome && (
<div className="bg-blue-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs font-black text-blue-700 uppercase"><Building2 size={14} /> Clínica: {os.clinica_nome}</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> Prazo {dataBR(os.prazo_entrega)}</span>}
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${noLab ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</span>
</div>
<EtapasStepper os={os} />
{os.garantia_ate && (<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)</div>)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-xs">
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor cobrado</span>{fmtBRL(os.valor)}</div>}
</div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
{/* Etapas do trabalho: avançar + imprimir recibo (térmica/laser) por etapa */}
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Etapas do trabalho</p>
<div className="space-y-1.5">
{FLUXO_OS.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
const ev = (os.eventos || []).find((e: any) => e.status === s);
// Etapas livres: pode ir para qualquer uma (pular/repetir/voltar), exceto 'entregue' (timeline).
const acaoLabel = atual ? 'Repetir' : done ? 'Voltar' : 'Ir';
return (
<div key={s} className="flex items-center gap-2 bg-gray-50 rounded-xl px-3 py-2">
{done ? <CheckCircle2 size={16} className="text-teal-600 shrink-0" /> : atual ? <div className="w-4 h-4 rounded-full bg-violet-600 shrink-0" /> : <Circle size={16} className="text-gray-300 shrink-0" />}
<span className={`text-xs font-black uppercase ${atual ? 'text-violet-700' : done ? 'text-gray-700' : 'text-gray-400'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[10px] text-gray-400">{ev ? dataBR(ev.created_at) : ''}</span>
<div className="flex-1" />
{!finalizado && s !== 'entregue' && <button disabled={busy} onClick={() => mudar(s)} className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase disabled:opacity-50 ${atual ? 'border border-violet-200 text-violet-700 hover:bg-violet-50' : 'bg-violet-600 text-white'}`}>{acaoLabel}</button>}
{!finalizado && s === 'entregue' && <span className="text-[9px] text-amber-500 font-black uppercase">aba Linha do tempo</span>}
<button onClick={() => imprimirCupom(s, 'termica')} title="Imprimir em impressora térmica (bobina 80mm)" className="p-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white"><Printer size={13} /></button>
<button onClick={() => imprimirCupom(s, 'laser')} title="Imprimir em impressora laser (folha A4)" className="px-2 py-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white text-[9px] font-black uppercase">A4</button>
</div>
);
})}
</div>
<p className="text-[9px] text-gray-300 mt-1.5 uppercase font-bold flex items-center gap-1"><Printer size={10} /> = térmica 80mm · A4 = laser · etapas podem ser repetidas/puladas</p>
</div>
{!finalizado && <AjustesSecao os={os} modo={modo} onChanged={carregar} />}
</div>
)}
{tab === 'timeline' && (
<div className="space-y-4">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><History size={12} /> Linha do tempo</p>
<div className="space-y-2.5">
{os.eventos?.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2.5 text-xs">
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : ev.status === 'custodia' ? 'bg-blue-400' : 'bg-violet-400'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
<span className="text-gray-400 block text-[11px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
</div>
</div>
))}
</div>
{!finalizado && modo === 'clinica' && (
<div className="pt-3 border-t border-gray-100 space-y-2">
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto (aba Itens) antes de entregar</p>}
<div className="flex gap-2">
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
</div>
</div>
)}
{naGarantia && modo === 'clinica' && (
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
)}
</div>
)}
{tab === 'itens' && (
<div className="space-y-5">
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
</div>
)}
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
</div>
</div>
</div>
);
};
// ── Seção: Ajustes do trabalho (cor/material, valor ao paciente, troca) ─────
const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: () => void }> = ({ os, modo, onChanged }) => {
const toast = useToast();
const [cor, setCor] = useState(os.cor || '');
const [material, setMaterial] = useState(os.material || '');
const [delta, setDelta] = useState('');
const [obsVal, setObsVal] = useState('');
const [trocaOpen, setTrocaOpen] = useState(false);
const [novoTrab, setNovoTrab] = useState('');
const [motivo, setMotivo] = useState('');
const [busy, setBusy] = useState(false);
const salvarCM = async () => {
setBusy(true);
try { await HybridBackend.editarProteseOS(os.id, { cor: cor.trim(), material: material.trim() }); toast.success('TRABALHO ATUALIZADO.'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const ajustar = async (sinal: number) => {
const v = parseFloat(delta);
if (!(v > 0)) { toast.error('INFORME O VALOR DO AJUSTE.'); return; }
setBusy(true);
try { await HybridBackend.ajustarValorProtese(os.id, sinal * v, obsVal.trim() || undefined); toast.success('VALOR DO PACIENTE AJUSTADO.'); setDelta(''); setObsVal(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const trocar = async () => {
if (!novoTrab.trim() || !motivo.trim()) { toast.error('INFORME O NOVO TRABALHO E O MOTIVO.'); return; }
setBusy(true);
try { await HybridBackend.trocarTrabalhoProtese(os.id, { tipo: novoTrab.trim(), motivo: motivo.trim() }); toast.success('TRABALHO TROCADO.'); setTrocaOpen(false); setNovoTrab(''); setMotivo(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="border-t border-gray-100 pt-4 space-y-3">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Pencil size={12} /> Ajustes do trabalho</p>
{/* Cor / material */}
<div className="grid grid-cols-2 gap-2">
<input value={cor} onChange={e => setCor(e.target.value)} placeholder="Cor (ex.: A2)" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<input value={material} onChange={e => setMaterial(e.target.value)} placeholder="Material" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
</div>
<button disabled={busy} onClick={salvarCM} className="px-3 py-1.5 rounded-lg bg-gray-700 text-white text-[11px] font-black uppercase disabled:opacity-50">Salvar cor / material</button>
{/* Ajuste de valor ao paciente — só clínica (protético não vê) */}
{modo === 'clinica' && (
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<p className="text-[10px] font-black text-gray-500 uppercase">Ajustar valor do paciente {Number(os.valor) > 0 ? `· atual ${fmtBRL(os.valor)}` : ''}</p>
<div className="flex gap-2">
<input value={delta} onChange={e => setDelta(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-24 p-2 bg-white border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
<input value={obsVal} onChange={e => setObsVal(e.target.value)} placeholder="Motivo (ex.: pino extra)" className="flex-1 p-2 bg-white border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
</div>
<div className="flex gap-2">
<button disabled={busy} onClick={() => ajustar(1)} className="flex-1 py-1.5 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase disabled:opacity-50">+ Acrescentar</button>
<button disabled={busy} onClick={() => ajustar(-1)} className="flex-1 py-1.5 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50"> Reduzir</button>
</div>
</div>
)}
{/* Trocar trabalho */}
<button onClick={() => setTrocaOpen(o => !o)} className="px-3 py-1.5 rounded-lg border border-violet-200 text-violet-700 text-[11px] font-black uppercase hover:bg-violet-50 flex items-center gap-1.5"><RefreshCw size={12} /> Solicitar troca de trabalho</button>
{trocaOpen && (
<div className="bg-violet-50 rounded-xl p-3 space-y-2">
<input value={novoTrab} onChange={e => setNovoTrab(e.target.value)} placeholder="Novo trabalho (ex.: PPR metálica)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da troca (obrigatório)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
<button disabled={busy} onClick={trocar} className="w-full py-2 rounded-lg bg-violet-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Confirmar troca</button>
</div>
)}
</div>
);
};
// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ────────────
const OCOR_CATS: Record<string, string> = {
nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível',
parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta',
ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro',
};
const OCOR_STATUS_COR: Record<string, string> = {
aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700',
resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700',
};
const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [cat, setCat] = useState('danificado');
const [desc, setDesc] = useState('');
const [resp, setResp] = useState('');
const [busy, setBusy] = useState(false);
const [respostas, setRespostas] = useState<Record<string, string>>({});
const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado';
const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase());
const abrir = async () => {
if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; }
setBusy(true);
try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); }
catch (e) { err(e); } finally { setBusy(false); }
};
const responder = async (id: string) => {
const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; }
setBusy(true);
try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); }
};
const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } };
const anexar = async (id: string, e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return; setBusy(true);
try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; }
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
</div>
{open && (
<div className="bg-red-50 rounded-xl p-3 space-y-2">
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
</div>
)}
<div className="space-y-3">
{(os.ocorrencias || []).map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
</div>
<p className="text-xs text-gray-600">{o.descricao}</p>
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
{o.status !== 'resolvida' && (
<div className="space-y-1.5 pt-1 border-t border-gray-50">
<div className="flex gap-1.5">
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
</div>
<div className="flex gap-1.5">
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
</div>
</div>
)}
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
</div>
))}
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
</div>
<p className="text-[9px] text-gray-300 uppercase font-bold"> ocorrências resolvidas pesarão em indicadores (futuro).</p>
</div>
);
};
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
return (
<div className="space-y-5">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><MapPin size={12} /> Onde está o trabalho</p>
<div className={`rounded-2xl p-5 text-center ${noLab ? 'bg-violet-50' : 'bg-blue-50'}`}>
<div className={`w-14 h-14 rounded-2xl mx-auto flex items-center justify-center text-white ${noLab ? 'bg-violet-600' : 'bg-blue-600'}`}>{noLab ? <FlaskConical size={26} /> : <Building2 size={26} />}</div>
<p className={`mt-3 font-black uppercase text-sm ${noLab ? 'text-violet-700' : 'text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</p>
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">{noLab ? 'O protético está com o trabalho' : 'O trabalho está na clínica'}</p>
</div>
{podeEditar && (
<div className="flex gap-2">
<button disabled={busy || noLab} onClick={() => onMover('laboratorio')} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><FlaskConical size={14} /> Protético retirou</button>
<button disabled={busy || !noLab} onClick={() => onMover('clinica')} className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><Building2 size={14} /> Devolveu à clínica</button>
</div>
)}
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Movimentações</p>
<div className="space-y-2">
{(os.custodia || []).map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className="text-[9px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-200 text-gray-600">{c.de_local === 'clinica' ? 'Clínica' : 'Lab'} {c.para_local === 'clinica' ? 'Clínica' : 'Lab'}</span>
<span className="text-[10px] text-gray-400 flex-1 truncate">{c.actor_nome} · {dataHoraBR(c.created_at)}</span>
</div>
))}
{(os.custodia || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Sem movimentações registradas.</p>}
</div>
</div>
</div>
);
};
// ── Stepper visual das etapas da OS ─────────────────────────────────────────
const FLUXO_OS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
if (os.status === 'cancelado') {
return <div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase flex items-center gap-2"><X size={14} /> OS cancelada</div>;
}
const idxAtual = FLUXO_OS.indexOf(os.status);
const dataDe = (s: string) => { const ev = os.eventos?.find((e: any) => e.status === s); return ev ? dataBR(ev.created_at) : ''; };
return (
<div className="flex items-start overflow-x-auto pb-1">
{FLUXO_OS.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
return (
<React.Fragment key={s}>
<div className="flex flex-col items-center min-w-[58px]">
{done ? <CheckCircle2 size={20} className="text-teal-600" />
: atual ? <div className="w-5 h-5 rounded-full bg-violet-600 ring-4 ring-violet-100" />
: <Circle size={20} className="text-gray-200" />}
<span className={`text-[8px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-violet-700' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[8px] text-gray-300 leading-tight">{dataDe(s)}</span>
</div>
{i < FLUXO_OS.length - 1 && <div className={`flex-1 h-0.5 mt-2.5 min-w-[10px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
</React.Fragment>
);
})}
</div>
);
};
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [nome, setNome] = useState('');
const [qtd, setQtd] = useState('1');
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
const [busy, setBusy] = useState(false);
const add = async () => {
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
setBusy(true);
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
<div className="space-y-1.5">
{(os.componentes || []).map((c: any) => (
<div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
<span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
))}
</div>
</div>
))}
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
<option value="enviado">Enviado</option>
<option value="devolvido">Devolvido</option>
</select>
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<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>
)}
</div>
);
};
// ── Seção: Fotos (anexar com legenda, autor, remover) ───────────────────────
const FotosSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [legenda, setLegenda] = useState('');
const [busy, setBusy] = useState(false);
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return;
setBusy(true);
try { await HybridBackend.uploadProteseFoto(os.id, file, legenda.trim() || undefined); setLegenda(''); onChanged(); }
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); } finally { setBusy(false); e.target.value = ''; }
};
const del = async (id: string) => { try { await HybridBackend.deleteProteseFoto(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Camera size={12} /> Fotos do trabalho</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{(os.fotos || []).map((f: any) => (
<div key={f.id} className="rounded-lg border border-gray-100 overflow-hidden group relative">
<a href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-full h-24 object-cover" /></a>
{podeEditar && <button onClick={() => del(f.id)} className="absolute top-1 right-1 bg-white/90 rounded-md p-1 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={12} /></button>}
<div className="px-2 py-1">
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate">{f.legenda}</p>}
<p className="text-[9px] text-gray-400 truncate">{f.uploaded_nome || '—'} · {dataBR(f.created_at)}</p>
</div>
</div>
))}
{(os.fotos || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase col-span-full">Nenhuma foto anexada.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<input value={legenda} onChange={e => setLegenda(e.target.value)} placeholder="Legenda da próxima foto (opcional)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<label className={`flex items-center gap-1.5 px-3 rounded-lg border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
<ImagePlus size={14} /> Anexar
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</div>
)}
</div>
);
};
// ── Seção: Pagamentos (dois lados) ──────────────────────────────────────────
const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEditar: boolean; onChanged: () => void }> = ({ os, modo, podeEditar, onChanged }) => {
const toast = useToast();
const [lado, setLado] = useState<'lab' | 'clinica'>(modo === 'bancada' ? 'lab' : 'clinica');
const [valor, setValor] = useState('');
const [forma, setForma] = useState('');
const [busy, setBusy] = useState(false);
const pags = os.pagamentos || [];
const totalLab = pags.filter((p: any) => p.lado === 'lab').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
const totalClinica = pags.filter((p: any) => p.lado === 'clinica').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
const add = async () => {
const v = parseFloat(valor);
if (!(v > 0)) { toast.error('INFORME UM VALOR.'); return; }
setBusy(true);
try { await HybridBackend.addProtesePagamento(os.id, { lado, valor: v, forma: forma.trim() || undefined }); setValor(''); setForma(''); onChanged(); }
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()); } };
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>
{/* O protético (modo bancada) NÃO vê o que o paciente paga — só o que recebe do lab. */}
<div className={`grid ${modo === 'bancada' ? 'grid-cols-1' : 'grid-cols-2'} gap-2 mb-2`}>
{modo === 'clinica' && <div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>}
<div className="bg-violet-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-violet-600 uppercase">Pago ao laboratório</p><p className="text-sm font-black text-violet-700">{fmtBRL(totalLab)}</p></div>
</div>
<div className="space-y-1.5">
{pags.map((p: any) => (
<div key={p.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase ${p.lado === 'lab' ? 'bg-violet-100 text-violet-700' : 'bg-green-100 text-green-700'}`}>{p.lado === 'lab' ? 'Lab' : 'Paciente'}</span>
<span className="font-black text-gray-700">{fmtBRL(p.valor)}</span>
<span className="text-[10px] text-gray-400 flex-1 truncate">{p.forma ? `${p.forma} · ` : ''}{p.recebido_nome} · {dataBR(p.data)}</span>
{podeEditar && <button onClick={() => del(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div>
))}
{pags.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum pagamento registrado.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
{modo === 'clinica' ? (
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
<option value="clinica">Paciente</option>
<option value="lab">Lab</option>
</select>
) : (
<span className="p-2 bg-violet-50 rounded-lg text-xs font-black text-violet-700 uppercase flex items-center">Lab</span>
)}
<input value={valor} onChange={e => setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
<input value={forma} onChange={e => setForma(e.target.value)} placeholder="Forma (pix...)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<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>
)}
</div>
);
};
// ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ───────
const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const toast = useToast();
const confirm = useConfirm();
const [itens, setItens] = useState<any[]>([]);
const [novo, setNovo] = useState({ nome: '', preco: '', garantia_meses: '12' });
const [editId, setEditId] = useState<string | null>(null);
const [edit, setEdit] = useState<any>({});
const carregar = useCallback(() => {
HybridBackend.getProteseProdutos().then(r => setItens(Array.isArray(r) ? r : [])).catch(() => setItens([]));
}, []);
useEffect(() => { carregar(); }, [carregar]);
const add = async () => {
if (!novo.nome.trim()) { toast.error('INFORME O NOME.'); return; }
try { await HybridBackend.createProteseProduto({ nome: novo.nome.trim(), preco: Number(novo.preco) || 0, garantia_meses: Number(novo.garantia_meses) || 12 });
setNovo({ nome: '', preco: '', garantia_meses: '12' }); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const salvarEdit = async (id: string) => {
try { await HybridBackend.updateProteseProduto(id, { nome: edit.nome, preco: Number(edit.preco) || 0, garantia_meses: Number(edit.garantia_meses) || 12 }); setEditId(null); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const remover = async (id: string) => {
if (!(await confirm('REMOVER ESTE PRODUTO DO CATÁLOGO?'))) return;
try { await HybridBackend.deleteProteseProduto(id); carregar(); } catch { toast.error('ERRO AO REMOVER.'); }
};
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-lg max-h-[90vh] 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">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Tags size={16} className="text-violet-600" /> Meus produtos de prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-5 space-y-2">
{/* Adicionar */}
<div className="flex gap-2 items-end bg-gray-50 rounded-xl p-3">
<div className="flex-1"><label className="text-[9px] font-black text-gray-500 uppercase">Produto</label><input value={novo.nome} onChange={e => setNovo({ ...novo, nome: e.target.value })} placeholder="Coroa de Zircônia" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
<div className="w-20"><label className="text-[9px] font-black text-gray-500 uppercase">Preço</label><input type="number" value={novo.preco} onChange={e => setNovo({ ...novo, preco: e.target.value })} placeholder="0" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
<div className="w-16"><label className="text-[9px] font-black text-gray-500 uppercase">Gar.(m)</label><input type="number" value={novo.garantia_meses} onChange={e => setNovo({ ...novo, garantia_meses: e.target.value })} className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
<button onClick={add} className="p-2 rounded-lg bg-violet-600 text-white"><Plus size={16} /></button>
</div>
{/* Lista */}
{itens.map(p => (
<div key={p.id} className="flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-100">
{editId === p.id ? (
<>
<input value={edit.nome} onChange={e => setEdit({ ...edit, nome: e.target.value })} className="flex-1 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
<input type="number" value={edit.preco} onChange={e => setEdit({ ...edit, preco: e.target.value })} className="w-20 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
<input type="number" value={edit.garantia_meses} onChange={e => setEdit({ ...edit, garantia_meses: e.target.value })} className="w-14 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
<button onClick={() => salvarEdit(p.id)} className="text-green-600"><Check size={16} /></button>
</>
) : (
<>
<span className="flex-1 text-sm font-bold text-gray-700">{p.nome}</span>
<span className="text-sm text-gray-500">{fmtBRL(p.preco)}</span>
<span className="text-[10px] text-gray-400">{p.garantia_meses}m</span>
<button onClick={() => { setEditId(p.id); setEdit({ nome: p.nome, preco: p.preco, garantia_meses: p.garantia_meses }); }} className="text-gray-300 hover:text-violet-600"><Pencil size={14} /></button>
<button onClick={() => remover(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={14} /></button>
</>
)}
</div>
))}
{itens.length === 0 && <p className="text-center text-gray-400 text-sm py-6">Nenhum produto. Adicione acima.</p>}
</div>
</div>
</div>
);
};
// ── View principal ──────────────────────────────────────────────────────────
export const ProteseView: React.FC = () => {
const role = HybridBackend.getCurrentRole();
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
const toast = useToast();
const confirm = useConfirm();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filtro, setFiltro] = useState('');
const [novaOS, setNovaOS] = useState(false);
const [detalhe, setDetalhe] = useState<string | null>(null);
const [produtosModal, setProdutosModal] = useState(false);
const carregar = useCallback(() => {
setLoading(true);
const p = modo === 'bancada'
? HybridBackend.getProteseBancada(filtro || undefined)
: HybridBackend.getProteseOS(filtro ? { status: filtro } : {});
p.then(r => setLista(Array.isArray(r) ? r : [])).catch(() => setLista([])).finally(() => setLoading(false));
}, [modo, filtro]);
useEffect(() => { carregar(); }, [carregar]);
const excluir = async (id: string) => {
if (!(await confirm('EXCLUIR ESTA OS?'))) return;
try { await HybridBackend.deleteProteseOS(id); toast.success('OS EXCLUÍDA.'); carregar(); }
catch { toast.error('ERRO AO EXCLUIR.'); }
};
const STATUS_FILTRO = modo === 'bancada'
? ['', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue']
: ['', 'solicitado', 'recebido', 'producao', 'finalizado', 'entregue', 'cancelado'];
return (
<div className="p-4 md:p-8 max-w-5xl mx-auto">
<PageHeader
title={modo === 'bancada' ? 'Bancada de Próteses' : 'Próteses'}
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
>
{modo === 'clinica' ? (
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
<Plus size={16} /> Nova OS
</button>
) : (
<>
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
<button onClick={carregar} className="flex items-center gap-2 border border-gray-200 text-gray-500 px-3 py-2 rounded-xl font-black text-xs uppercase"><RefreshCw size={14} /> Atualizar</button>
</>
)}
</PageHeader>
{/* Filtros de status */}
<div className="flex gap-2 flex-wrap mb-4 mt-2">
{STATUS_FILTRO.map(s => (
<button key={s} onClick={() => setFiltro(s)}
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase ${filtro === s ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-500'}`}>
{s === '' ? 'Todas' : STATUS_LABEL[s]}
</button>
))}
</div>
{loading ? (
<p className="text-center text-gray-400 py-12 text-sm">Carregando...</p>
) : lista.length === 0 ? (
<div className="text-center py-16">
<FlaskConical size={40} className="mx-auto text-gray-200 mb-3" />
<p className="text-gray-400 text-sm font-bold uppercase">{modo === 'bancada' ? 'Nenhum trabalho na bancada' : 'Nenhuma OS de prótese'}</p>
</div>
) : (
<div className="space-y-2">
{lista.map(os => (
<div key={os.id} onClick={() => setDetalhe(os.id)}
className="bg-white rounded-2xl border border-gray-100 p-4 flex items-center gap-3 hover:border-violet-200 cursor-pointer transition-colors">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${os.atrasada ? 'bg-red-100' : 'bg-violet-100'}`}>
<FlaskConical size={18} className={os.atrasada ? 'text-red-500' : 'text-violet-600'} />
</div>
<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')}
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
</p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
{modo === 'bancada' && os.clinica_nome && (
<p className="text-[10px] font-black text-blue-600 uppercase truncate flex items-center gap-1"><Building2 size={10} /> {os.clinica_nome}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}
<Badge s={os.status} />
{modo === 'clinica' && os.status !== 'entregue' && (
<button onClick={e => { e.stopPropagation(); excluir(os.id); }} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
)}
<ChevronRight size={16} className="text-gray-300" />
</div>
</div>
))}
</div>
)}
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
</div>
);
};
export default ProteseView;