diff --git a/backend/server.js b/backend/server.js index 5de3479..49f06e5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3856,6 +3856,26 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma. +// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno, +// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN. +app.post('/api/protese/proteticos-internos', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const nome = (req.body?.nome || '').trim(); + if (!nome) return res.status(400).json({ error: 'Nome obrigatório.' }); + try { + const id = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); + const email = `protetico_${id}@interno.local`; + await pool.query( + "INSERT INTO usuarios (id, nome, email, senha, role, celular, especialidade_dir) VALUES ($1,$2,$3,$4,'protetico',$5,$6)", + [id, nome.toUpperCase(), email, await hashSenha(gerarSenhaTemp()), req.body?.telefone || null, req.body?.especialidade || null]); + await pool.query("INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING", + [`v_${id}_${req.clinicaId}`, id, req.clinicaId]); + try { await seedProteseProdutos(id); } catch (e) { console.error('[seed interno]', e.message); } + res.json({ success: true, id, nome: nome.toUpperCase() }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // ── Catálogo de produtos do protético (preço fixo, geral) ─────────────────── // O protético gerencia o próprio catálogo (escopo por usuário). Lazy-seed na 1ª leitura. app.get('/api/protese/produtos', authGuard, async (req, res) => { @@ -4410,6 +4430,32 @@ app.get('/api/p/:token', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Modo 1 — carteira do protético pelo link: trabalhos (andamento/finalizados) + mini financeiro +// (fechamento mensal). Escopo = a RELAÇÃO clínica↔protético da OS do token (não vaza outras clínicas). +app.get('/api/p/:token/carteira', 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 clinicaId = ctx.os.clinica_id, proteticoId = ctx.os.protetico_id; + const { rows: trab } = await pool.query( + "SELECT id, tipo, status, prazo_entrega, custo, paciente_nome, updated_at FROM protese_os WHERE clinica_id=$1 AND protetico_id=$2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200", + [clinicaId, proteticoId]); + const { rows: pag } = await pool.query( + "SELECT to_char(p.data,'YYYY-MM') AS mes, SUM(p.valor)::numeric AS total, COUNT(*)::int AS n FROM protese_os_pagamento p JOIN protese_os o ON o.id=p.os_id WHERE p.lado='lab' AND o.clinica_id=$1 AND o.protetico_id=$2 GROUP BY mes ORDER BY mes DESC", + [clinicaId, proteticoId]); + const { rows: cl } = await pool.query("SELECT nome_fantasia FROM clinicas WHERE id=$1", [clinicaId]); + const map = t => ({ id: t.id, tipo: t.tipo, status: t.status, prazo_entrega: t.prazo_entrega, custo: parseFloat(t.custo || 0), paciente_nome: t.paciente_nome, updated_at: t.updated_at }); + const andamento = trab.filter(t => t.status !== 'entregue' && t.status !== 'cancelado').map(map); + const finalizados = trab.filter(t => t.status === 'entregue').map(map); + const por_mes = pag.map(m => ({ mes: m.mes, total: parseFloat(m.total || 0), n: m.n })); + res.json({ + clinica_nome: cl[0]?.nome_fantasia || null, + andamento, finalizados, + financeiro: { por_mes, total_pago: Math.round(por_mes.reduce((s, m) => s + m.total, 0) * 100) / 100 }, + }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Modo 1 (1b) — ações do protético PELO LINK (sem conta). Auditadas como " (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]); diff --git a/doc/MODO1-LINK-SEGURO-PROTESE.md b/doc/MODO1-LINK-SEGURO-PROTESE.md index 82127ad..8637992 100644 --- a/doc/MODO1-LINK-SEGURO-PROTESE.md +++ b/doc/MODO1-LINK-SEGURO-PROTESE.md @@ -104,5 +104,14 @@ marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o **Modo 1 concluído (1a leitura + 1b ação + 1c gestão).** +### ✅ Extras (24/jun) +- **Carteira pela link** (`GET /api/p/:token/carteira`): botão "Meus trabalhos" na página pública → + trabalhos em andamento + finalizados + **mini financeiro mensal** (valores pagos ao protético). + Escopo = a relação clínica↔protético da OS do token (não vaza outras clínicas; sem dados do paciente). +- **Protético INTERNO (sem conta)** (`POST /api/protese/proteticos-internos`): a clínica cadastra um + protético que não usa a plataforma — cria um usuário "mudo" (e-mail placeholder `..@interno.local`, + senha-fantasma; `usuarios.email` é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). Botão + "Cadastrar protético interno" na Nova OS. A partir daí recebe OS e acompanha pelo link, sem conta. + > Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a > camada de token público. diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 4f86f05..f9576be 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1187,12 +1187,24 @@ export const HybridBackend = { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar'); return await res.json(); }, + // Cadastro de protético INTERNO (sem conta) pela clínica + cadastrarProteticoInterno: async (body: { nome: string; telefone?: string; especialidade?: string }) => { + const cid = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/proteticos-internos?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cadastrar'); + 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(); }, + getCarteiraPublic: async (token: string) => { + const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/carteira`); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao carregar.'); + 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 }) }); diff --git a/frontend/views/ProtesePublicView.tsx b/frontend/views/ProtesePublicView.tsx index 7de4ccb..82c28b3 100644 --- a/frontend/views/ProtesePublicView.tsx +++ b/frontend/views/ProtesePublicView.tsx @@ -1,5 +1,5 @@ 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 { 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'; @@ -21,6 +21,57 @@ const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> 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
; + const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => ( +
+ {STATUS_LABEL[t.status] || t.status} +
+

{t.tipo}

+

{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}

+
+ {Number(t.custo) > 0 && {fmtBRL(t.custo)}} +
+ ); + return ( +
+ {carteira.clinica_nome &&
{carteira.clinica_nome}
} + + {/* Mini financeiro */} +
+

Recebido desta clínica

+

{fmtBRL(carteira.financeiro?.total_pago || 0)}

+
+ {(carteira.financeiro?.por_mes || []).map((m: any) => ( +
+ {mesBR(m.mes)} + {fmtBRL(m.total)} +
+ ))} + {(carteira.financeiro?.por_mes || []).length === 0 &&

Nenhum pagamento registrado ainda.

} +
+
+ + +
+ {(carteira.andamento || []).map((t: any) => )} + {(carteira.andamento || []).length === 0 &&

Nada em produção.

} +
+
+ +
+ {(carteira.finalizados || []).map((t: any) => )} + {(carteira.finalizados || []).length === 0 &&

Nenhum entregue ainda.

} +
+
+

Valores recebidos do laboratório · sem dados do paciente

+
+ ); +}; + export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => { const toast = useToast(); const [os, setOs] = useState(null); @@ -28,8 +79,12 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); + const [tela, setTela] = useState<'os' | 'carteira'>('os'); + const [carteira, setCarteira] = useState(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, ok: string) => { setBusy(true); @@ -66,10 +121,14 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {

ScoreOdonto

Acompanhamento de prótese

- {os.finalizada ? 'Somente leitura' : 'Acompanhamento'} + {tela === 'os' + ? + : } -
+ {tela === 'carteira' && } + + {tela === 'os' &&
{/* Cabeçalho da OS */}
@@ -218,7 +277,7 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => { Criar conta grátis

ScoreOdonto · Provedor de Prótese

-
+
} ); }; diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 08f3fab..26d2425 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -27,6 +27,38 @@ const Badge: React.FC<{ s: string }> = ({ s }) => ( {STATUS_LABEL[s] || s} ); +// ── Cadastro de protético INTERNO (sem conta), inline na Nova OS ──────────── +const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({ onCriado }) => { + const toast = useToast(); + const [open, setOpen] = useState(false); + const [nome, setNome] = useState(''); + const [tel, setTel] = useState(''); + const [busy, setBusy] = useState(false); + const criar = async () => { + if (!nome.trim()) { toast.error('INFORME O NOME.'); return; } + setBusy(true); + try { const r = await HybridBackend.cadastrarProteticoInterno({ nome: nome.trim(), telefone: tel.trim() || undefined }); toast.success('PROTÉTICO CADASTRADO.'); setOpen(false); setNome(''); setTel(''); onCriado(r.id); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + return ( +
+ {!open ? ( + + ) : ( +
+ setNome(e.target.value)} placeholder="Nome do protético" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" /> + setTel(e.target.value)} placeholder="Telefone (opcional)" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" /> +
+ + +
+

Só da sua clínica · ele acompanha pelo link, sem criar conta.

+
+ )} +
+ ); +}; + // ── Modal: Nova OS (lado clínica) ─────────────────────────────────────────── const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { const toast = useToast(); @@ -123,7 +155,7 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o {labs.map(l => )} - {labs.length === 0 &&

Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.

} + { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} /> {/* Produto (catálogo do laboratório — preço fixo) */}