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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:29:49 +02:00

1214 lines
95 KiB
TypeScript
Raw Permalink 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, useMemo } from 'react';
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2, FileText, Star } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
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>
);
// ── Cadastro de protético INTERNO (sem conta), inline na Nova OS ────────────
const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({ onCriado }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [nome, setNome] = useState('');
const [tel, setTel] = useState('');
const [busy, setBusy] = useState(false);
const criar = async () => {
if (!nome.trim()) { toast.error('INFORME O NOME.'); return; }
setBusy(true);
try { const r = await HybridBackend.cadastrarProteticoInterno({ nome: nome.trim(), telefone: tel.trim() || undefined }); toast.success('PROTÉTICO CADASTRADO.'); setOpen(false); setNome(''); setTel(''); onCriado(r.id); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="mt-1.5">
{!open ? (
<button onClick={() => setOpen(true)} className="text-[10px] font-black text-violet-600 uppercase flex items-center gap-1"><Plus size={11} /> Cadastrar protético interno (sem conta)</button>
) : (
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome do protético" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="Telefone (opcional)" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
<div className="flex gap-2">
<button disabled={busy} onClick={criar} className="flex-1 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50">Cadastrar</button>
<button onClick={() => setOpen(false)} className="px-3 py-2 rounded-lg border border-gray-200 text-gray-500 text-[11px] font-black uppercase">Cancelar</button>
</div>
<p className="text-[9px] text-gray-400 uppercase"> da sua clínica · ele acompanha pelo link, sem criar conta.</p>
</div>
)}
</div>
);
};
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const toast = useToast();
const [pickerKey, setPickerKey] = useState(0);
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.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>
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
</div>
{/* Produto (catálogo do laboratório — preço fixo) */}
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
{form.protetico_id && produtos.length > 0 ? (
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
placeholder="Selecione..." title="Produto do laboratório"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
)}
{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>
<div className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="—" title="Dentista"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
</div>
{/* Material + cor + dentes */}
<div className="grid grid-cols-3 gap-3">
<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>
)}
{os.status === 'entregue' && modo === 'clinica' && <AvaliacaoSecao os={os} onChanged={carregar} />}
</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' && (
<div className="space-y-5">
<MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />
<AgendaColetaSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
{modo === 'clinica' && <CompartilharLinkSecao os={os} />}
</div>
)}
{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: Compartilhar link de acompanhamento com o protético (Modo 1) ─────
const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => {
const toast = useToast();
const confirm = useConfirm();
const [st, setSt] = useState<any>(null); // null=carregando | {ativo,...}
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getProteseLinkStatus(os.id).then(setSt).catch(() => setSt({ ativo: false })), [os.id]);
useEffect(() => { carregar(); }, [carregar]);
const acao = async (fn: () => Promise<any>, ok: string) => {
setBusy(true);
try { await fn(); await carregar(); toast.success(ok); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const copiar = async () => { try { await navigator.clipboard.writeText(st.url); toast.success('LINK COPIADO.'); } catch { toast.error('NÃO FOI POSSÍVEL COPIAR.'); } };
const revogar = async () => { if (await confirm('REVOGAR O LINK? O PROTÉTICO PERDE O ACESSO.')) acao(() => HybridBackend.revogarProteseLink(os.id), 'LINK REVOGADO.'); };
const waText = st?.url ? encodeURIComponent(`Acompanhe a prótese (${os.tipo}) do paciente ${os.paciente_nome || ''}: ${st.url}`) : '';
return (
<div className="border-t border-gray-100 pt-4 space-y-2">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Share2 size={12} /> Acompanhamento do protético</p>
{!st ? (
<p className="text-[11px] text-gray-300 uppercase font-bold">Carregando</p>
) : !st.ativo ? (
<>
<p className="text-[11px] text-gray-400">Gere um link para o protético acompanhar esta OS <b>sem precisar de conta</b>.</p>
<button disabled={busy} onClick={() => acao(() => HybridBackend.gerarProteseLink(os.id), 'LINK GERADO.')} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Share2 size={13} /> Gerar link de acompanhamento</button>
</>
) : (
<div className="space-y-2">
<input readOnly value={st.url} onClick={e => e.currentTarget.select()} className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] text-gray-600 outline-none" />
<div className="flex gap-2">
<a href={`https://wa.me/?text=${waText}`} target="_blank" rel="noreferrer" className="flex-1 py-2 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase text-center">WhatsApp</a>
<button onClick={copiar} className="flex-1 py-2 rounded-lg border border-gray-200 text-gray-600 text-[11px] font-black uppercase">Copiar</button>
</div>
<div className="flex items-center justify-between text-[9px] font-bold text-gray-400 uppercase">
<span>{st.acessos || 0} acesso(s){st.last_access_at ? ` · último ${dataBR(st.last_access_at)}` : ' · ainda não aberto'}</span>
<button disabled={busy} onClick={revogar} className="text-red-500 hover:text-red-600 disabled:opacity-50">Revogar</button>
</div>
<p className="text-[9px] text-gray-400 uppercase">{st.expires_at ? `Expira em ${dataBR(st.expires_at)} · ` : ''}sem CPF nem valores do paciente.</p>
</div>
)}
</div>
);
};
// ── Seção: Agendar coleta / entrega (integra com a custódia) ────────────────
const AgendaColetaSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [tipo, setTipo] = useState<'coleta' | 'entrega'>('coleta');
const [data, setData] = useState('');
const [resp, setResp] = useState('');
const [busy, setBusy] = useState(false);
const add = async () => {
setBusy(true);
try { await HybridBackend.agendarColeta(os.id, { tipo, data_hora: data || undefined, responsavel: resp.trim() || undefined }); toast.success('AGENDADO.'); setData(''); setResp(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const marcar = async (id: string, status: 'realizada' | 'cancelada') => { try { await HybridBackend.statusColeta(id, status); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div className="border-t border-gray-100 pt-4 space-y-2">
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Clock size={12} /> Coleta / entrega</p>
{(os.coletas || []).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-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.tipo === 'coleta' ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{c.tipo}</span>
<span className="text-gray-600 flex-1 truncate">{c.data_hora ? dataHoraBR(c.data_hora) : 'sem data'}{c.responsavel ? ` · ${c.responsavel}` : ''}</span>
{c.status === 'agendada'
? (podeEditar && <button onClick={() => marcar(c.id, 'realizada')} className="text-[9px] font-black text-green-600 uppercase">Realizar</button>)
: <span className={`text-[8px] font-black uppercase ${c.status === 'realizada' ? 'text-green-600' : 'text-gray-400'}`}>{c.status}</span>}
</div>
))}
{(os.coletas || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma coleta agendada.</p>}
{podeEditar && (
<div className="flex gap-2 flex-wrap">
<select value={tipo} onChange={e => setTipo(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="coleta">Coleta</option>
<option value="entrega">Entrega</option>
</select>
<input type="datetime-local" value={data} onChange={e => setData(e.target.value)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] outline-none focus:border-teal-400" />
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável" className="flex-1 min-w-[100px] 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: Avaliar o laboratório (após a entrega, lado clínica) ─────────────
const AvaliacaoSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
const toast = useToast();
const [estrelas, setEstrelas] = useState<number>(os.avaliacao?.estrelas || 0);
const [coment, setComent] = useState<string>(os.avaliacao?.comentario || '');
const [busy, setBusy] = useState(false);
const salvar = async () => {
if (!estrelas) { toast.error('DÊ UMA NOTA DE 1 A 5.'); return; }
setBusy(true);
try { await HybridBackend.avaliarProteseOS(os.id, { estrelas, comentario: coment.trim() || undefined }); toast.success('AVALIAÇÃO ENVIADA.'); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<div className="pt-3 border-t border-gray-100">
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Star size={12} /> Avaliar o laboratório</p>
<div className="flex gap-1 mb-2">
{[1, 2, 3, 4, 5].map(n => (
<button key={n} onClick={() => setEstrelas(n)} className={n <= estrelas ? 'text-amber-400' : 'text-gray-200'}><Star size={26} fill={n <= estrelas ? 'currentColor' : 'none'} /></button>
))}
</div>
<textarea value={coment} onChange={e => setComent(e.target.value)} rows={2} placeholder="Comentário (opcional)" className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-amber-400 resize-none" />
<button disabled={busy} onClick={salvar} className="mt-2 px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">{os.avaliacao ? 'Atualizar avaliação' : 'Enviar avaliação'}</button>
</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()); } };
// 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>
{/* 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>
)}
{/* 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>
);
};
// ── 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 ──────────────────────────────────────────────────────────
// ── Modal: Cotações (lado clínica) — pedir a N labs, comparar e escolher ────
const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => void }> = ({ onClose, onEscolhida }) => {
const toast = useToast();
const confirm = useConfirm();
const [aba, setAba] = useState<'novo' | 'lista'>('lista');
const [rfqs, setRfqs] = useState<any[]>([]);
const [labs, setLabs] = useState<any[]>([]);
const [descricao, setDescricao] = useState('');
const [dentes, setDentes] = useState('');
const [prazo, setPrazo] = useState('');
const [sel, setSel] = useState<string[]>([]);
const [buscaLab, setBuscaLab] = useState('');
const [busy, setBusy] = useState(false);
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
useEffect(() => { carregar(); HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); }, [carregar]);
const toggleLab = (id: string) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
const criar = async () => {
if (!descricao.trim()) { toast.error('DESCREVA O TRABALHO.'); return; }
if (!sel.length) { toast.error('SELECIONE AO MENOS UM LABORATÓRIO.'); return; }
setBusy(true);
try { await HybridBackend.criarRfq({ descricao: descricao.trim(), dentes: dentes.trim() || undefined, prazo_desejado: prazo || undefined, proteticos: sel }); toast.success('COTAÇÃO ENVIADA AOS LABORATÓRIOS.'); setDescricao(''); setDentes(''); setPrazo(''); setSel([]); setAba('lista'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const escolher = async (rfq: any, cot: any) => {
if (!(await confirm(`ESCOLHER ${cot.protetico_nome} POR ${fmtBRL(cot.valor)}? UMA OS SERÁ CRIADA.`))) return;
setBusy(true);
try { await HybridBackend.escolherCotacao(rfq.id, cot.id); toast.success('OS CRIADA A PARTIR DA COTAÇÃO.'); carregar(); onEscolhida(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(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 h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="flex border-b border-gray-100 px-2">
{(['lista', 'novo'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-5">
{aba === 'novo' ? (
<div className="space-y-3">
<textarea value={descricao} onChange={e => setDescricao(e.target.value)} rows={3} placeholder="Descreva o trabalho (ex.: Coroa sobre implante, dente 36, cor A2)" className="w-full p-3 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400 resize-none" />
<div className="grid grid-cols-2 gap-2">
<input value={dentes} onChange={e => setDentes(e.target.value)} placeholder="Dentes (ex.: 36)" className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
<input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).map(l => (
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
{l.interno && <span className="text-[8px] font-black text-teal-600 uppercase">interno</span>}
{l.nota != null && <span className="text-[9px] font-black text-amber-600"> {l.nota}</span>}
</button>
))}
</div>
</div>
<button disabled={busy} onClick={criar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50">Enviar pedido a {sel.length} laboratório(s)</button>
</div>
) : (
rfqs.length === 0 ? <p className="text-center text-gray-300 font-bold uppercase text-sm py-12">Nenhum pedido de cotação ainda.</p> : (
<div className="space-y-3">
{rfqs.map(r => (
<div key={r.id} className="border border-gray-100 rounded-2xl p-3">
<div className="flex items-center justify-between gap-2">
<p className="font-black text-gray-800 text-sm">{r.descricao}</p>
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${r.status === 'fechada' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>{r.status === 'fechada' ? 'fechada' : 'aberta'}</span>
</div>
<div className="mt-2 space-y-1.5">
{(r.cotacoes || []).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="font-bold text-gray-700 flex-1 truncate">{c.protetico_nome}</span>
{c.status === 'convidada' ? <span className="text-[10px] text-gray-400 uppercase">aguardando</span>
: <><span className="font-black text-gray-800">{fmtBRL(c.valor)}</span>{c.prazo_dias ? <span className="text-[10px] text-gray-400">{c.prazo_dias}d</span> : null}</>}
{c.status === 'escolhida' && <span className="text-[8px] font-black text-green-700 uppercase">escolhida</span>}
{r.status === 'aberta' && c.status === 'enviada' && <button disabled={busy} onClick={() => escolher(r, c)} className="px-2 py-1 rounded-lg bg-[#2d6a4f] text-white text-[9px] font-black uppercase disabled:opacity-50">Escolher</button>}
</div>
))}
</div>
</div>
))}
</div>
)
)}
</div>
</div>
</div>
);
};
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[]>([]);
// Rótulos desambiguados de homônimos (só faz sentido na bancada, onde o protético lê o nome sem CPF).
const rotuloPac = useMemo(() => modo === 'bancada' ? rotulosPaciente(lista) : new Map<string, string>(), [lista, modo]);
const [loading, setLoading] = useState(true);
const [filtro, setFiltro] = useState('');
const [novaOS, setNovaOS] = useState(false);
const [detalhe, setDetalhe] = useState<string | null>(null);
const [produtosModal, setProdutosModal] = useState(false);
const [cotacoesOpen, setCotacoesOpen] = 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={() => setCotacoesOpen(true)} className="flex items-center gap-2 border border-violet-200 text-violet-700 px-4 py-2 rounded-xl font-black text-sm uppercase hover:bg-violet-50"><FileText size={16} /> Cotações</button>
<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' ? (rotuloPac.get(os.id) || os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
</p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
{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)} />}
{cotacoesOpen && <CotacoesClinicaModal onClose={() => setCotacoesOpen(false)} onEscolhida={() => carregar()} />}
</div>
);
};
export default ProteseView;