Files
scoreodonto.com/frontend/views/ProtesePublicView.tsx
T
VPS 4 Builder 672c3b01e5
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
Provedor de Prótese SEM conta (doc/MODO1-LINK-SEGURO-PROTESE):

- Token por OS: tabela protese_os_link (aleatório 24B base64url, expira 60 dias, revogável,
  conta acessos). POST /protese/os/:id/link (gera/recupera) + POST .../link/revogar.
- Leitura pública GET /api/p/:token (sem auth): payload curado com a mesma blindagem soLab —
  SEM paciente_id (CPF) e SEM valor cobrado ao paciente; mostra trabalho, etapas, componentes,
  fotos, custódia, ocorrências, histórico e custo do lab.
- Página pública ProtesePublicView (mobile-first 320px+), interceptada no index.tsx antes do
  app autenticado; CTA "Criar conta grátis". Botão Gerar link / WhatsApp / Copiar na aba
  Monitoramento (modo clínica).
- Validado em DEV: leitura, blindagem (CPF/valor ocultos), token inválido→404, revogação→404.

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

184 lines
12 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 } from 'react';
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
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>
);
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
const [os, setOs] = useState<any>(null);
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')).finally(() => setLoading(false));
}, [token]);
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">Somente leitura</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>
{/* 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>
);
};