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>
This commit is contained in:
@@ -4146,6 +4146,67 @@ app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab).
|
||||
app.post('/api/protese/os/:id/link', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const { rows: ex } = await pool.query(
|
||||
"SELECT token FROM protese_os_link WHERE os_id=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1", [acc.os.id]);
|
||||
let token = ex[0]?.token;
|
||||
if (!token) {
|
||||
token = crypto.randomBytes(24).toString('base64url');
|
||||
await pool.query("INSERT INTO protese_os_link (token, os_id, protetico_id, created_by, expires_at) VALUES ($1,$2,$3,$4, NOW() + INTERVAL '60 days')",
|
||||
[token, acc.os.id, acc.os.protetico_id, uid]);
|
||||
await logEventoOS(acc.os, uid, 'Link de acompanhamento gerado para o protético', 'link');
|
||||
}
|
||||
const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com';
|
||||
res.json({ success: true, token, url: `${base}/p/${token}` });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/os/:id/link/revogar', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
await pool.query("UPDATE protese_os_link SET revoked_at=NOW() WHERE os_id=$1 AND revoked_at IS NULL", [acc.os.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente.
|
||||
app.get('/api/p/:token', async (req, res) => {
|
||||
try {
|
||||
const { rows: lk } = await pool.query(
|
||||
"SELECT * FROM protese_os_link WHERE token=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", [req.params.token]);
|
||||
if (!lk.length) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||||
const { rows } = await pool.query("SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id=protese_os.clinica_id) AS clinica_nome FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [lk[0].os_id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
await pool.query("UPDATE protese_os_link SET last_access_at=NOW(), acessos=acessos+1 WHERE token=$1", [req.params.token]);
|
||||
const [ev, ft, cm, oc, cu] = await Promise.all([
|
||||
pool.query("SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||||
pool.query("SELECT id, legenda, uploaded_nome, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at", [os.id]),
|
||||
pool.query("SELECT id, nome, quantidade, COALESCE(direcao,'enviado') AS direcao, COALESCE(status_conferencia,'pendente') AS status_conferencia, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||||
pool.query("SELECT id, categoria, descricao, status, autor_nome, resposta, resposta_nome, created_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||||
pool.query("SELECT de_local, para_local, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||||
]);
|
||||
// Payload curado: o protético vê o trabalho e o próprio custo, NUNCA CPF nem valor ao paciente.
|
||||
res.json({
|
||||
id: os.id, tipo: os.tipo, tipo_os: os.tipo_os, status: os.status,
|
||||
clinica_nome: os.clinica_nome, dentista_nome: os.dentista_nome, paciente_nome: os.paciente_nome,
|
||||
dentes: os.dentes, material: os.material, cor: os.cor, observacoes: os.observacoes,
|
||||
prazo_entrega: os.prazo_entrega, garantia_ate: os.garantia_ate, garantia_meses: os.garantia_meses,
|
||||
custo: parseFloat(os.custo || 0), local_atual: os.local_atual || 'clinica',
|
||||
eventos: ev.rows,
|
||||
fotos: ft.rows.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
componentes: cm.rows, ocorrencias: oc.rows, custodia: cu.rows,
|
||||
somente_leitura: true,
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Detalhe + histórico de eventos + fotos.
|
||||
app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -6276,6 +6337,19 @@ async function runMigrations() {
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`,
|
||||
// Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id).
|
||||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`,
|
||||
// Modo 1 — link seguro /p/TOKEN: o protético acompanha a OS sem conta. Token por OS, revogável.
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_link (
|
||||
token TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
protetico_id TEXT,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_access_at TIMESTAMPTZ,
|
||||
acessos INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -81,9 +81,15 @@ histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele).
|
||||
marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o score do protético.
|
||||
|
||||
## 8. Faseamento sugerido
|
||||
- **1a (núcleo):** tabela + `GET /api/p/:token` (leitura) + página pública read-only + botão "enviar".
|
||||
- **✅ 1a (núcleo) — IMPLEMENTADA 24/jun:** tabela `protese_os_link` (token aleatório 24 bytes
|
||||
base64url, expira em 60 dias, revogável); `POST /api/protese/os/:id/link` (gera/recupera) +
|
||||
`POST …/link/revogar`; `GET /api/p/:token` (leitura pública curada — sem CPF, sem valor do
|
||||
paciente; conta acessos). Página pública `/p/:token` (`ProtesePublicView`, mobile-first 320px+,
|
||||
interceptada no `index.tsx` antes do app autenticado); botão "Gerar link / WhatsApp / Copiar" na
|
||||
aba Monitoramento (modo clínica). CTA "Criar conta grátis". Validado em DEV (leitura, blindagem,
|
||||
token inválido→404, revogação→404).
|
||||
- **1b (ação):** custódia + status + foto pelo link (escrita), com auditoria.
|
||||
- **1c (conversão):** CTA de cadastro com herança de histórico + revogação/expiração na UI.
|
||||
- **1c (conversão):** herança de histórico ao criar conta + revogação/expiração na UI.
|
||||
|
||||
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
|
||||
> camada de token público.
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import { ProtesePublicView } from './views/ProtesePublicView.tsx';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
||||
import { ToastProvider } from './contexts/ToastContext.tsx';
|
||||
import { ConfirmProvider } from './contexts/ConfirmContext.tsx';
|
||||
@@ -34,13 +35,15 @@ window.addEventListener('vite:preloadError', () => {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
// Modo 1 — link seguro: /p/TOKEN é página PÚBLICA (sem login), fora do app autenticado.
|
||||
const publicMatch = window.location.pathname.match(/^\/p\/(.+)$/);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
{publicMatch ? <ProtesePublicView token={decodeURIComponent(publicMatch[1])} /> : <App />}
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1124,6 +1124,18 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
// Modo 1: link seguro de acompanhamento da OS
|
||||
gerarProteseLink: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link');
|
||||
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)}`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.');
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
// Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade.
|
||||
getSalaReservas: async (salaId: string): Promise<any[]> => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
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 } from 'lucide-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 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -383,7 +383,12 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
|
||||
{tab === 'monitor' && (
|
||||
<div className="space-y-5">
|
||||
<MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />
|
||||
{modo === 'clinica' && <CompartilharLinkSecao os={os} />}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,6 +555,40 @@ 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 [busy, setBusy] = useState(false);
|
||||
const gerar = async () => {
|
||||
setBusy(true);
|
||||
try { const r = await HybridBackend.gerarProteseLink(os.id); setUrl(r.url); }
|
||||
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}`);
|
||||
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 ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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" />
|
||||
<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>
|
||||
)}
|
||||
</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';
|
||||
|
||||
Reference in New Issue
Block a user