Files
scoreodonto.com/frontend/views/ProtesePublicView.tsx
T
VPS 4 Builder 28dc27a289
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
Pelo link /p/TOKEN o protético (sem conta) passa a ATUAR na OS, tudo auditado como "<nome> (link)":
- POST /api/p/:token/custodia — confirmar recebimento / devolução (clínica↔lab)
- POST /api/p/:token/status — avançar a produção (recebido…finalizado); entregue/cancelado
  seguem exclusivos da clínica (400 pelo link)
- POST /api/p/:token/foto — anexar foto do trabalho
Página pública ganha a seção "Atualizar status" (custódia/etapa/foto) enquanto a OS não está
finalizada; header alterna Acompanhamento/Somente leitura.

Validado em DEV: recebimento, avanço de etapa, foto; entregue pelo link → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:03:52 +02:00

225 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.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 x = new Date(d); return isNaN(x.getTime()) ? dataBR(d) : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', 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 FLUXO = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
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 incorreta', ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho', outro: 'Outro' };
const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> = ({ titulo, Icon, children }) => (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Icon size={12} /> {titulo}</p>
{children}
</div>
);
const PROD_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
const toast = useToast();
const [os, setOs] = useState<any>(null);
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
const act = async (fn: () => Promise<any>, ok: string) => {
setBusy(true);
try { await fn(); toast.success(ok); await recarregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const receber = () => act(() => HybridBackend.custodiaPublic(token, 'laboratorio'), 'RECEBIMENTO CONFIRMADO.');
const devolver = () => act(() => HybridBackend.custodiaPublic(token, 'clinica'), 'DEVOLUÇÃO REGISTRADA.');
const avancar = (s: string) => act(() => HybridBackend.statusPublic(token, s), 'ETAPA ATUALIZADA.');
const anexar = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return;
act(() => HybridBackend.uploadFotoPublic(token, f), 'FOTO ANEXADA.'); e.target.value = '';
};
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="animate-spin text-teal-600" size={32} /></div>;
if (erro || !os) return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
<AlertTriangle size={40} className="text-amber-400" />
<p className="mt-3 font-black text-gray-700 uppercase text-sm">{erro || 'Link inválido'}</p>
<p className="text-xs text-gray-400 mt-1">Peça um novo link à clínica.</p>
</div>
);
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
const idxAtual = FLUXO.indexOf(os.status);
const cancelado = os.status === 'cancelado';
return (
<div className="min-h-screen bg-gray-50">
{/* Topo / marca */}
<header className="bg-[#2d6a4f] text-white px-4 py-3 flex items-center gap-2 sticky top-0 z-10">
<FlaskConical size={18} />
<div className="min-w-0 flex-1">
<p className="font-black text-sm leading-tight">ScoreOdonto</p>
<p className="text-[10px] text-white/70 uppercase tracking-wide leading-tight">Acompanhamento de prótese</p>
</div>
<span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-white/15 uppercase">{os.finalizada ? 'Somente leitura' : 'Acompanhamento'}</span>
</header>
<main className="max-w-lg mx-auto p-3 space-y-3">
{/* Cabeçalho da OS */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1 className="font-black text-gray-800 text-base leading-tight">{os.tipo}</h1>
<p className="text-xs text-gray-400 mt-0.5">OS {os.id}</p>
</div>
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase shrink-0 ${STATUS_COR[os.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[os.status] || os.status}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{os.clinica_nome && <span className="text-[10px] font-black text-blue-700 bg-blue-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><Building2 size={11} /> {os.clinica_nome}</span>}
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase flex items-center gap-1 ${noLab ? 'bg-violet-50 text-violet-700' : 'bg-blue-50 text-blue-700'}`}><MapPin size={11} /> {noLab ? 'Com o laboratório' : 'Na clínica'}</span>
{os.tipo_os === 'garantia' && <span className="text-[10px] font-black text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><ShieldCheck size={11} /> Garantia</span>}
</div>
</div>
{/* Ações do protético (sem conta) */}
{!os.finalizada && (
<Secao titulo="Atualizar status" Icon={Zap}>
<div className="space-y-2">
{noLab
? <button disabled={busy} onClick={devolver} className="w-full py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-50">Devolvi à clínica</button>
: <button disabled={busy} onClick={receber} className="w-full py-2.5 rounded-xl bg-[#2d6a4f] text-white font-black text-xs uppercase disabled:opacity-50">Recebi o trabalho</button>}
<div>
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Etapa de produção</p>
<div className="flex flex-wrap gap-1.5">
{PROD_FLUXO.map(s => (
<button key={s} disabled={busy || os.status === s} onClick={() => avancar(s)}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-[#2d6a4f] text-white' : 'border border-gray-200 text-gray-500'}`}>{STATUS_LABEL[s]}</button>
))}
</div>
</div>
<label className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
<ImagePlus size={15} /> Anexar foto do trabalho
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</div>
</Secao>
)}
{/* Etapas */}
<Secao titulo="Etapas" Icon={Clock}>
{cancelado ? (
<div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase">OS cancelada</div>
) : (
<div className="flex items-start overflow-x-auto pb-1 -mx-1 px-1">
{FLUXO.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
const ev = (os.eventos || []).find((e: any) => e.status === s);
return (
<React.Fragment key={s}>
<div className="flex flex-col items-center min-w-[52px]">
{done ? <CheckCircle2 size={18} className="text-teal-600" /> : atual ? <div className="w-4 h-4 rounded-full bg-[#2d6a4f] ring-4 ring-green-100" /> : <Circle size={18} className="text-gray-200" />}
<span className={`text-[7px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-[#2d6a4f]' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[7px] text-gray-300">{ev ? dataBR(ev.created_at) : ''}</span>
</div>
{i < FLUXO.length - 1 && <div className={`flex-1 h-0.5 mt-2 min-w-[8px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
</React.Fragment>
);
})}
</div>
)}
</Secao>
{/* Dados */}
<Secao titulo="Dados do trabalho" Icon={FlaskConical}>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
{os.paciente_nome && <div className="col-span-2"><span className="text-gray-400 font-bold uppercase text-[9px] block">Paciente</span>{os.paciente_nome}</div>}
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
{os.dentes && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentes</span>{os.dentes}</div>}
{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.prazo_entrega && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Prazo</span>{dataBR(os.prazo_entrega)}</div>}
{Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor do lab</span>{fmtBRL(os.custo)}</div>}
</div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3 mt-3">{os.observacoes}</p>}
{os.garantia_ate && <div className="bg-emerald-50 rounded-xl px-3 py-2 mt-3 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)}</div>}
</Secao>
{/* Componentes */}
{(os.componentes || []).length > 0 && (
<Secao titulo="Componentes" Icon={Package}>
<div className="space-y-1.5">
{os.componentes.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.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>
</div>
))}
</div>
</Secao>
)}
{/* Fotos */}
{(os.fotos || []).length > 0 && (
<Secao titulo="Fotos do trabalho" Icon={Camera}>
<div className="grid grid-cols-2 gap-2">
{os.fotos.map((f: any) => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="block rounded-lg overflow-hidden border border-gray-100">
<img src={f.url} alt="" className="w-full h-24 object-cover" />
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate px-2 py-1">{f.legenda}</p>}
</a>
))}
</div>
</Secao>
)}
{/* Ocorrências */}
{(os.ocorrencias || []).length > 0 && (
<Secao titulo="Ocorrências" Icon={AlertTriangle}>
<div className="space-y-2">
{os.ocorrencias.map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-2.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-[8px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-100 text-gray-600">{(o.status || '').replace('_', ' ')}</span>
<span className="font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
</div>
<p className="text-gray-600 mt-1">{o.descricao}</p>
{o.resposta && <p className="text-gray-500 mt-1"><b className="uppercase text-[9px]">Resposta:</b> {o.resposta}</p>}
</div>
))}
</div>
</Secao>
)}
{/* Histórico */}
<Secao titulo="Histórico" Icon={History}>
<div className="space-y-2">
{(os.eventos || []).map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 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' : 'bg-[#2d6a4f]'}`} />
<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-[10px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
</div>
</div>
))}
</div>
</Secao>
{/* CTA de conversão */}
<a href="/" className="block bg-[#2d6a4f] text-white rounded-2xl p-4 text-center active:scale-[0.99] transition-transform">
<p className="font-black text-sm uppercase">Gostou de acompanhar por aqui?</p>
<p className="text-[11px] text-white/80 mt-1">Crie sua conta grátis e gerencie seu laboratório: bancada, etapas, financeiro e score.</p>
<span className="inline-flex items-center gap-1 mt-2 bg-white text-[#2d6a4f] font-black text-xs uppercase px-4 py-2 rounded-xl">Criar conta grátis <ChevronRight size={14} /></span>
</a>
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">ScoreOdonto · Provedor de Prótese</p>
</main>
</div>
);
};