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>
284 lines
19 KiB
TypeScript
284 lines
19 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap, Briefcase, Wallet, ChevronLeft } 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'];
|
||
|
||
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) : m; };
|
||
|
||
// Carteira do protético (via link): trabalhos + mini financeiro mensal (relação com a clínica).
|
||
const CarteiraView: React.FC<{ carteira: any }> = ({ carteira }) => {
|
||
if (!carteira) return <div className="flex justify-center py-16"><Loader2 className="animate-spin text-teal-600" size={28} /></div>;
|
||
const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => (
|
||
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-xl px-3 py-2 text-xs">
|
||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${STATUS_COR[t.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[t.status] || t.status}</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-bold text-gray-700 truncate">{t.tipo}</p>
|
||
<p className="text-[10px] text-gray-400 truncate">{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}</p>
|
||
</div>
|
||
{Number(t.custo) > 0 && <span className="font-black text-gray-700 shrink-0">{fmtBRL(t.custo)}</span>}
|
||
</div>
|
||
);
|
||
return (
|
||
<main className="max-w-lg mx-auto p-3 space-y-3">
|
||
{carteira.clinica_nome && <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 text-[11px] font-black text-blue-700 uppercase flex items-center gap-1.5"><Building2 size={12} /> {carteira.clinica_nome}</div>}
|
||
|
||
{/* Mini financeiro */}
|
||
<div className="bg-[#2d6a4f] text-white rounded-2xl p-4">
|
||
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 flex items-center gap-1.5"><Wallet size={12} /> Recebido desta clínica</p>
|
||
<p className="text-2xl font-black mt-1">{fmtBRL(carteira.financeiro?.total_pago || 0)}</p>
|
||
<div className="mt-3 space-y-1">
|
||
{(carteira.financeiro?.por_mes || []).map((m: any) => (
|
||
<div key={m.mes} className="flex items-center justify-between text-xs bg-white/10 rounded-lg px-3 py-1.5">
|
||
<span className="font-bold capitalize">{mesBR(m.mes)}</span>
|
||
<span className="font-black">{fmtBRL(m.total)}</span>
|
||
</div>
|
||
))}
|
||
{(carteira.financeiro?.por_mes || []).length === 0 && <p className="text-[11px] opacity-70 uppercase font-bold">Nenhum pagamento registrado ainda.</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<Secao titulo={`Em andamento (${carteira.andamento?.length || 0})`} Icon={Zap}>
|
||
<div className="space-y-1.5">
|
||
{(carteira.andamento || []).map((t: any) => <Linha key={t.id} t={t} />)}
|
||
{(carteira.andamento || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nada em produção.</p>}
|
||
</div>
|
||
</Secao>
|
||
<Secao titulo={`Finalizados (${carteira.finalizados?.length || 0})`} Icon={CheckCircle2}>
|
||
<div className="space-y-1.5">
|
||
{(carteira.finalizados || []).map((t: any) => <Linha key={t.id} t={t} fin />)}
|
||
{(carteira.finalizados || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum entregue ainda.</p>}
|
||
</div>
|
||
</Secao>
|
||
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">Valores recebidos do laboratório · sem dados do paciente</p>
|
||
</main>
|
||
);
|
||
};
|
||
|
||
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 [tela, setTela] = useState<'os' | 'carteira'>('os');
|
||
const [carteira, setCarteira] = useState<any>(null);
|
||
|
||
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
|
||
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
|
||
const abrirCarteira = () => { setTela('carteira'); if (!carteira) HybridBackend.getCarteiraPublic(token).then(setCarteira).catch(() => setCarteira({ andamento: [], finalizados: [], financeiro: { por_mes: [], total_pago: 0 } })); };
|
||
|
||
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>
|
||
{tela === 'os'
|
||
? <button onClick={abrirCarteira} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><Briefcase size={11} /> Meus trabalhos</button>
|
||
: <button onClick={() => setTela('os')} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><ChevronLeft size={11} /> Voltar à OS</button>}
|
||
</header>
|
||
|
||
{tela === 'carteira' && <CarteiraView carteira={carteira} />}
|
||
|
||
{tela === 'os' && <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>
|
||
);
|
||
};
|