4673f3c606
- Protético interno: a clínica cadastra um protético que não usa a plataforma. Cria usuário "mudo" (e-mail placeholder @interno.local + senha-fantasma; usuarios.email é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). POST /protese/proteticos-internos; botão "Cadastrar protético interno" na Nova OS, que recarrega o dropdown e seleciona o novo. - Carteira no link /p/TOKEN: botão "Meus trabalhos" → trabalhos em andamento/finalizados + mini financeiro mensal (valores pagos ao protético). GET /api/p/:token/carteira, escopo da relação clínica↔protético do token (não vaza outras clínicas; sem dados do paciente). Validado em DEV: interno criado e acessível no dropdown; OS+link; carteira com andamento + R$250/2026-06. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1195 lines
94 KiB
TypeScript
1195 lines
94 KiB
TypeScript
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, 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';
|
||
|
||
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">Só 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 [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>
|
||
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
|
||
</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>
|
||
)}
|
||
{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">Só 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()); } };
|
||
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 ──────────────────────────────────────────────────────────
|
||
// ── 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 [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="space-y-1.5 max-h-56 overflow-y-auto">
|
||
{labs.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[]>([]);
|
||
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' ? (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;
|