feat(protese): Modo 1 fatia 1c — gestão do link na UI da clínica (status/acessos/revogar)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 56s

Fecha o Modo 1 (link seguro do protético sem conta):
- GET /api/protese/os/:id/link/status: link ativo, nº de acessos, último acesso e expiração.
- UI (aba Monitoramento, modo clínica): mostra status do link com Copiar / WhatsApp / Revogar
  e os contadores de acesso/expiração; gera sob demanda.

Conversão por herança é nativa (a OS já aponta para o protetico_id do destinatário). Validado em
DEV: acessos contados, expiração 60 dias, revogação → status inativo + leitura pública 404.

Modo 1 concluído (1a leitura + 1b ação + 1c gestão).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 07:12:03 +02:00
parent 8552e87665
commit 8040e70310
4 changed files with 50 additions and 10 deletions
+9
View File
@@ -1130,6 +1130,15 @@ export const HybridBackend = {
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link');
return await res.json();
},
getProteseLinkStatus: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
return await res.json().catch(() => ({ ativo: false }));
},
revogarProteseLink: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar');
return await res.json();
},
// Leitura PÚBLICA via token (sem login — não usa apiFetch/Authorization)
getProtesePublic: async (token: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}`);
+19 -9
View File
@@ -558,31 +558,41 @@ const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, on
// ── Seção: Compartilhar link de acompanhamento com o protético (Modo 1) ─────
const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => {
const toast = useToast();
const [url, setUrl] = useState('');
const confirm = useConfirm();
const [st, setSt] = useState<any>(null); // null=carregando | {ativo,...}
const [busy, setBusy] = useState(false);
const gerar = async () => {
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 { const r = await HybridBackend.gerarProteseLink(os.id); setUrl(r.url); }
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(url); toast.success('LINK COPIADO.'); } catch { toast.error('NÃO FOI POSSÍVEL COPIAR.'); } };
const waText = encodeURIComponent(`Acompanhe a prótese (${os.tipo}) do paciente ${os.paciente_nome || ''}: ${url}`);
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>
{!url ? (
{!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={gerar} 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>
<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={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" />
<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>
<p className="text-[9px] text-gray-400 uppercase">Somente leitura · expira em 60 dias · sem CPF nem valores do paciente.</p>
<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>