Compare commits

...

10 Commits

Author SHA1 Message Date
VPS 4 Builder d357f3a2e8 feat(protese): Nota ScoreOdonto pública no marketplace de profissionais
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
GET /api/profissionais/marketplace passa a trazer a 'nota' dos protéticos (helper
notasScorePorProtetico, mesma fórmula da Fase 5 — só com volume mínimo). Card do protético
no marketplace exibe ★ nota. Ordem mantida por proximidade; a nota é comparação visível.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 2e90a2ba5f chore(protese): remove menu legado do protético (higiene)
O protético opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo).
Remove as telas clínicas legadas (agenda/lancar-gto/proteses/tratamentos/dashboard) do
fallback do protético em Sidebar.tsx e da lista de permissões em App.tsx; o fallback fora do
contexto laboratório fica mínimo (notificações/clínicas/config/diretório).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 53cf2c52e6 docs(verdade-hoje): produção em v1.0.25 (8040e70)
build-and-promote / build (push) Successful in 48s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:18:54 +02:00
VPS 4 Builder 8040e70310 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>
2026-06-24 07:12:03 +02:00
VPS 4 Builder 8552e87665 docs(verdade-hoje): produção em v1.0.24 (28dc27a)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:07:42 +02:00
VPS 4 Builder 28dc27a289 feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
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
VPS 4 Builder f0b7a45d86 docs(verdade-hoje): produção em v1.0.23 (672c3b0)
build-and-promote / build (push) Successful in 59s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:57:18 +02:00
VPS 4 Builder 672c3b01e5 feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
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
VPS 4 Builder 5f4460e37d feat(cadastro): card "Protético" na vitrine de onboarding + desenho do Modo 1
- OnboardingChat: adiciona o card de atalho "Protéticos" (perfil profissional → protetico),
  ao lado de Dentistas/Biomédicos. O cadastro de protético já funcionava via "Sou profissional",
  faltava o atalho na vitrine.
- doc/MODO1-LINK-SEGURO-PROTESE: desenho do "Provedor sem conta" — link seguro /p/TOKEN
  (token por OS, revogável; rotas públicas com a mesma blindagem soLab; página pública read+ação;
  CTA de conversão com herança de histórico). Evolução futura, sem refatoração.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:37:41 +02:00
VPS 4 Builder e05231663f docs(verdade-hoje): produção em v1.0.22 (84f4a79)
build-and-promote / build (push) Successful in 55s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:25:20 +02:00
12 changed files with 628 additions and 13 deletions
+178 -1
View File
@@ -4146,6 +4146,167 @@ app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Nota ScoreOdonto por protético (mapa id→nota), p/ ranking no dropdown e no marketplace.
async function notasScorePorProtetico(ids) {
const notas = {};
if (!ids || !ids.length) return notas;
const { rows: ag } = await pool.query(
`SELECT protetico_id,
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
const { rows: ocs } = await pool.query(
`SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]);
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
for (const g of ag) {
if (g.entregues >= SCORE_MIN_OS) {
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
const nota = 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3);
notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
}
}
return notas;
}
// 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 }); }
});
// Status do link ativo (para a UI da clínica gerir: copiar/revogar, ver acessos/expiração).
app.get('/api/protese/os/:id/link/status', 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 } = await pool.query(
"SELECT token, expires_at, last_access_at, acessos 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]);
if (!rows.length) return res.json({ ativo: false });
const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com';
res.json({ ativo: true, url: `${base}/p/${rows[0].token}`, expires_at: rows[0].expires_at, last_access_at: rows[0].last_access_at, acessos: rows[0].acessos });
} 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,
finalizada: os.status === 'entregue' || os.status === 'cancelado',
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Modo 1 (1b) — ações do protético PELO LINK (sem conta). Auditadas como "<nome> (link)".
async function resolveLinkToken(token) {
const { rows } = 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())", [token]);
if (!rows.length) return null;
const { rows: os } = await pool.query("SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [rows[0].os_id]);
if (!os.length) return null;
return { link: rows[0], os: os[0] };
}
async function logEventoLink(os, link, status, observacao) {
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)",
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, status, observacao, link.protetico_id, nome]);
return nome;
}
app.post('/api/p/:token/custodia', async (req, res) => {
try {
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
if (!para) return res.status(400).json({ error: 'para_local inválido.' });
const de = os.local_atual || 'clinica';
if (de === para) return res.status(409).json({ error: `Já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
[`pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, os.id, os.clinica_id, de, para, os.status, link.protetico_id, nome]);
await pool.query("UPDATE protese_os SET local_atual=$1 WHERE id=$2", [para, os.id]);
await logEventoLink(os, link, 'custodia', para === 'laboratorio' ? 'Protético recebeu o trabalho (via link)' : 'Trabalho devolvido à clínica (via link)');
res.json({ success: true, local_atual: para });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/p/:token/status', async (req, res) => {
try {
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const PROD = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
if (!PROD.includes(req.body?.status)) return res.status(400).json({ error: 'Etapa inválida (somente produção pelo link).' });
if (os.status === 'entregue' || os.status === 'cancelado') return res.status(409).json({ error: `OS já está "${os.status}".` });
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW() WHERE id=$2', [req.body.status, os.id]);
await logEventoLink(os, link, req.body.status, null);
res.json({ success: true, status: req.body.status });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/p/:token/foto', ortoUpload.single('foto'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, link.protetico_id, nome]);
await logEventoLink(os, link, 'foto', 'Foto anexada (via link)');
res.json({ success: true, id: fid });
} 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 +6437,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)
@@ -7035,7 +7209,10 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
const { rows } = await pool.query(
`SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir AS especialidade, raio_atuacao_km, bio_profissional, ${distSelect}
FROM usuarios ${where} ${order} LIMIT 200`, params);
res.json(rows);
// Nota ScoreOdonto nos protéticos (score público no marketplace). Mantém a ordem por
// proximidade; a nota é exibida no card para a clínica comparar.
const notas = await notasScorePorProtetico(rows.filter(r => r.role === 'protetico').map(r => r.id));
res.json(rows.map(r => ({ ...r, nota: r.role === 'protetico' ? (notas[r.id] ?? null) : undefined })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
+108
View File
@@ -0,0 +1,108 @@
# Modo 1 — Provedor de Prótese SEM conta (link seguro `/p/TOKEN`)
> Desenho (24/jun/2026). Evolução registrada como "fora de escopo" em `PROVEDOR-PROTESE-DECISAO.md`.
> Princípio (decisão arquitetural): **a clínica opera 100% da OS; o protético não precisa ter conta.**
> O link é a porta de entrada do ecossistema — acompanha hoje, vira conta amanhã.
## Objetivo
A clínica cria/gere a OS normalmente. O protético recebe um **link seguro** (WhatsApp/e-mail) e,
**sem login**, acompanha e atua na OS dele. Com CTA para criar conta grátis (conversão).
```
Clínica cria OS ──► gera link /p/TOKEN ──► WhatsApp/e-mail ──► Protético abre (sem conta)
├─ vê a OS (sem CPF/valores do paciente)
├─ confirma recebimento/devolução
├─ avança etapa / anexa foto
└─ responde ocorrência
"Crie sua conta grátis" ──► cadastro (card Protético)
```
## 1. Escopo do token: **por OS** (não por protético)
Escopo mínimo = menor superfície de risco. Um token dá acesso a **uma OS**. Vantagens: revogável
individualmente, expira por trabalho, não expõe a carteira inteira do protético. (Evolução: um token
"carteira" por protético-na-clínica que lista várias OS — fica para depois.)
## 2. Modelo de dados (revogável e auditável)
```sql
CREATE TABLE IF NOT EXISTS protese_os_link (
token TEXT PRIMARY KEY, -- aleatório longo (≥ 32 bytes base62), NÃO sequencial
os_id TEXT NOT NULL,
protetico_id TEXT, -- a quem se destina (rastreio)
created_by TEXT, -- quem na clínica gerou
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- opcional (ex.: +30 dias); null = sem expirar
revoked_at TIMESTAMPTZ, -- revogação manual
last_access_at TIMESTAMPTZ,
acessos INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id);
```
> Preferir tabela a JWT puro: permite **revogar** e **auditar acessos**. O token nunca contém dados.
## 3. Rotas públicas (sem `authGuard`; o token é a credencial)
Todas resolvem `os_id` pelo token (valida não-revogado, não-expirado) e aplicam a **mesma blindagem
do provedor**: sem `paciente_id`/CPF, sem valores do paciente (reusa a lógica `soLab`).
| Rota | Ação |
|---|---|
| `GET /api/p/:token` | OS: nº, clínica, dentista, paciente (nome, conforme permissão), prazo, status, observações, fotos, componentes, ocorrências, custódia. **Sem CPF/valores do paciente.** |
| `POST /api/p/:token/custodia` | confirmar recebimento / devolução (clínica↔lab) |
| `POST /api/p/:token/status` | avançar a produção (recebido→…→finalizado) |
| `POST /api/p/:token/foto` | anexar foto do trabalho (+ ocorrência) |
| `POST /api/p/:token/ocorrencias/:id/resposta` | responder ocorrência (contraditório) |
Cada ação registra no histórico da OS um **actor "Protético (link)"** com o `protetico_id` do token —
mantendo a auditoria. Mesmas validações de status/foto já existentes.
## 4. Geração e envio (lado clínica)
Na OS (modal, aba Etapas ou Monitoramento): botão **"Enviar ao protético"** →
`POST /api/protese/os/:id/link` gera/recupera o token e devolve a URL
`https://scoreodonto.com/p/TOKEN`. Atalhos: **WhatsApp** (`https://wa.me/?text=…`) e **e-mail**
(`mailto:`). Botão **"Revogar link"** para invalidar.
## 5. Frontend público `/p/:token`
Rota **fora do app autenticado** (sem Sidebar/login). Página enxuta que reusa as seções do
`OSDetailModal` em modo "público/lab" (etapas, fotos, componentes, custódia, ocorrências — sem
financeiro/clientes). Topo com a marca + rodapé fixo:
> *"Gostou de acompanhar por aqui? Crie sua conta grátis e gerencie todos os seus laboratórios."*
> → leva ao cadastro **já com o card Protético** (adicionado) e e-mail pré-preenchido.
## 6. Segurança (checklist)
- Token aleatório ≥ 32 bytes, base62, **não sequencial**; comparação em tempo constante.
- Escopo de **1 OS**; expiração opcional; **revogável**; rate-limit por token/IP.
- **Sem CPF e sem valores do paciente** (mesma blindagem `soLab` da Fase 1).
- Ações registram actor no histórico (auditoria); `last_access_at`/`acessos` para detectar abuso.
- OS `entregue`/`cancelado` → link vira **somente leitura**.
## 7. Conversão (o "porquê de negócio")
O link é aquisição: o protético experimenta o produto sem fricção e, ao criar conta, **herda o
histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). Liga direto ao
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) — 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) — IMPLEMENTADA 24/jun:** o protético, pelo link, **confirma recebimento/devolução**
(`POST /api/p/:token/custodia`), **avança a produção** (`/status`, só `recebido…finalizado` — nunca
entregue/cancelado, que são da clínica) e **anexa foto** (`/foto`). Tudo auditado como
`"<nome> (link)"`. Página pública ganha a seção "Atualizar status" (botões de custódia, etapa e foto)
enquanto a OS não está finalizada. Validado em DEV: recebimento, avanço, foto, e `entregue` pelo link
→ 400 (bloqueado).
- **✅ 1c (gestão) — IMPLEMENTADA 24/jun:** UI da clínica (aba Monitoramento) gere o link:
`GET /api/protese/os/:id/link/status` traz **link ativo, nº de acessos, último acesso e expiração**;
botões **Copiar / WhatsApp / Revogar**. Validado em DEV: acessos contados, expiração 60 dias,
revogação → status inativo + leitura pública 404.
> Conversão por herança: nativa — a OS já aponta para o `protetico_id` do destinatário, então ao
> entrar/criar conta ele já é dono do histórico. (Vincular um cadastro NOVO com e-mail diferente às
> OS é caso de borda — fica para evolução, evitando duplicar usuário.)
**Modo 1 concluído (1a leitura + 1b ação + 1c gestão).**
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
> camada de token público.
+13 -3
View File
@@ -106,6 +106,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### Fora de escopo (futuro)
Marketplace **transacional** (propostas/contratação dentro da plataforma) e o Modo 1 (protético sem
conta via link seguro `/p/TOKEN`) seguem como evolução — a base de dados/score já os suporta.
### Pós-roadmap (entregue depois das Fases 1-5)
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
laboratório; fallback mínimo fora dele.
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
Ordem mantida por proximidade; a nota é critério de comparação visível.
### Ainda em aberto (futuro)
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
+3 -3
View File
@@ -9,7 +9,7 @@
### 1. Onde está o código?
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.21 · commit `064f36d`**.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.25 · commit `8040e70`**.
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
### 2. Onde está o banco?
@@ -28,7 +28,7 @@
-**Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
### 5. Como versiona?
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.21 · `064f36d` · PROD**.
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.25 · `8040e70` · PROD**.
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
-**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
@@ -39,4 +39,4 @@
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.21**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.25**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
+1 -1
View File
@@ -329,7 +329,7 @@ const App: React.FC = () => {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
+3 -1
View File
@@ -149,7 +149,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});
+4 -1
View File
@@ -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>
+39
View File
@@ -1124,6 +1124,45 @@ 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();
},
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)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.');
return await res.json();
},
// Ações PÚBLICAS pelo link (Modo 1, fatia 1b) — sem login
custodiaPublic: async (token: string, para_local: 'clinica' | 'laboratorio') => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar.');
return await res.json();
},
statusPublic: async (token: string, status: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avançar etapa.');
return await res.json();
},
uploadFotoPublic: async (token: string, file: File) => {
const fd = new FormData();
fd.append('foto', file);
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/foto`, { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto.');
return await res.json();
},
// Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade.
getSalaReservas: async (salaId: string): Promise<any[]> => {
+2 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen,
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen, FlaskConical,
ArrowRight, ArrowLeft, ChevronLeft, Loader2, Send, Check, CheckCircle2, User as UserIcon
} from 'lucide-react';
@@ -17,6 +17,7 @@ const SLIDES: { icon: any; accent: string; title: string; desc: string; cta: str
{ icon: Briefcase, accent: '#0d9488', title: 'Consultórios', desc: 'Para quem voa solo: agenda inteligente e cobrança sem complicação.', cta: 'Cadastrar meu consultório', pre: { perfil: 'consultorio' } },
{ icon: Stethoscope, accent: '#7c3aed', title: 'Dentistas', desc: 'Sua agenda única — sem overbooking, mesmo atendendo em vários lugares.', cta: 'Sou dentista', pre: { perfil: 'profissional', profissao: 'dentista' } },
{ icon: Sparkles, accent: '#db2777', title: 'Biomédicos', desc: 'Harmonização orofacial e estética com prontuário e fotos por procedimento.', cta: 'Sou biomédico(a)', pre: { perfil: 'profissional', profissao: 'biomedico' } },
{ icon: FlaskConical, accent: '#2d6a4f', title: 'Protéticos', desc: 'Laboratório completo: bancada, ordens, etapas, componentes, financeiro e score.', cta: 'Sou protético(a)', pre: { perfil: 'profissional', profissao: 'protetico' } },
{ icon: DoorOpen, accent: '#0d9488', title: 'Salas', desc: 'Alugue ou anuncie salas por hora, dia ou mês. Valor fixo ou negociado.', cta: 'Tenho salas para alugar', pre: { perfil: 'sala' } },
];
+224
View File
@@ -0,0 +1,224 @@
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>
);
};
+51 -2
View File
@@ -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,50 @@ 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 confirm = useConfirm();
const [st, setSt] = useState<any>(null); // null=carregando | {ativo,...}
const [busy, setBusy] = useState(false);
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 { 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(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>
{!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={() => 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={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>
<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>
);
};
// ── 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';
@@ -44,6 +44,7 @@ interface Profissional {
raio_atuacao_km?: number | null;
bio_profissional?: string | null;
dist_km?: number | string | null;
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
}
interface Proposta {
@@ -699,6 +700,7 @@ export const ProfissionaisPlugin: React.FC = () => {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto"> {p.nota}</span>}
</div>
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
</div>