feat(protese): cadastro de protético interno (sem conta) + carteira no link mágico
- Protético interno: a clínica cadastra um protético que não usa a plataforma. Cria usuário "mudo" (e-mail placeholder @interno.local + senha-fantasma; usuarios.email é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). POST /protese/proteticos-internos; botão "Cadastrar protético interno" na Nova OS, que recarrega o dropdown e seleciona o novo. - Carteira no link /p/TOKEN: botão "Meus trabalhos" → trabalhos em andamento/finalizados + mini financeiro mensal (valores pagos ao protético). GET /api/p/:token/carteira, escopo da relação clínica↔protético do token (não vaza outras clínicas; sem dados do paciente). Validado em DEV: interno criado e acessível no dropdown; OS+link; carteira com andamento + R$250/2026-06. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "<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]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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 <div className="flex justify-center py-16"><Loader2 className="animate-spin text-teal-600" size={28} /></div>;
|
||||
const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => (
|
||||
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-xl px-3 py-2 text-xs">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${STATUS_COR[t.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[t.status] || t.status}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-700 truncate">{t.tipo}</p>
|
||||
<p className="text-[10px] text-gray-400 truncate">{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}</p>
|
||||
</div>
|
||||
{Number(t.custo) > 0 && <span className="font-black text-gray-700 shrink-0">{fmtBRL(t.custo)}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<main className="max-w-lg mx-auto p-3 space-y-3">
|
||||
{carteira.clinica_nome && <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 text-[11px] font-black text-blue-700 uppercase flex items-center gap-1.5"><Building2 size={12} /> {carteira.clinica_nome}</div>}
|
||||
|
||||
{/* Mini financeiro */}
|
||||
<div className="bg-[#2d6a4f] text-white rounded-2xl p-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 flex items-center gap-1.5"><Wallet size={12} /> Recebido desta clínica</p>
|
||||
<p className="text-2xl font-black mt-1">{fmtBRL(carteira.financeiro?.total_pago || 0)}</p>
|
||||
<div className="mt-3 space-y-1">
|
||||
{(carteira.financeiro?.por_mes || []).map((m: any) => (
|
||||
<div key={m.mes} className="flex items-center justify-between text-xs bg-white/10 rounded-lg px-3 py-1.5">
|
||||
<span className="font-bold capitalize">{mesBR(m.mes)}</span>
|
||||
<span className="font-black">{fmtBRL(m.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(carteira.financeiro?.por_mes || []).length === 0 && <p className="text-[11px] opacity-70 uppercase font-bold">Nenhum pagamento registrado ainda.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Secao titulo={`Em andamento (${carteira.andamento?.length || 0})`} Icon={Zap}>
|
||||
<div className="space-y-1.5">
|
||||
{(carteira.andamento || []).map((t: any) => <Linha key={t.id} t={t} />)}
|
||||
{(carteira.andamento || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nada em produção.</p>}
|
||||
</div>
|
||||
</Secao>
|
||||
<Secao titulo={`Finalizados (${carteira.finalizados?.length || 0})`} Icon={CheckCircle2}>
|
||||
<div className="space-y-1.5">
|
||||
{(carteira.finalizados || []).map((t: any) => <Linha key={t.id} t={t} fin />)}
|
||||
{(carteira.finalizados || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum entregue ainda.</p>}
|
||||
</div>
|
||||
</Secao>
|
||||
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">Valores recebidos do laboratório · sem dados do paciente</p>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
|
||||
const toast = useToast();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
@@ -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<any>(null);
|
||||
|
||||
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
|
||||
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
|
||||
const abrirCarteira = () => { setTela('carteira'); if (!carteira) HybridBackend.getCarteiraPublic(token).then(setCarteira).catch(() => setCarteira({ andamento: [], finalizados: [], financeiro: { por_mes: [], total_pago: 0 } })); };
|
||||
|
||||
const act = async (fn: () => Promise<any>, ok: string) => {
|
||||
setBusy(true);
|
||||
@@ -66,10 +121,14 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
|
||||
<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>
|
||||
{tela === 'os'
|
||||
? <button onClick={abrirCarteira} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><Briefcase size={11} /> Meus trabalhos</button>
|
||||
: <button onClick={() => setTela('os')} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><ChevronLeft size={11} /> Voltar à OS</button>}
|
||||
</header>
|
||||
|
||||
<main className="max-w-lg mx-auto p-3 space-y-3">
|
||||
{tela === 'carteira' && <CarteiraView carteira={carteira} />}
|
||||
|
||||
{tela === 'os' && <main className="max-w-lg mx-auto p-3 space-y-3">
|
||||
{/* Cabeçalho da OS */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -218,7 +277,7 @@ export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
|
||||
<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>
|
||||
</main>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,38 @@ const Badge: React.FC<{ s: string }> = ({ s }) => (
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
|
||||
);
|
||||
|
||||
// ── 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 (
|
||||
<div className="mt-1.5">
|
||||
{!open ? (
|
||||
<button onClick={() => setOpen(true)} className="text-[10px] font-black text-violet-600 uppercase flex items-center gap-1"><Plus size={11} /> Cadastrar protético interno (sem conta)</button>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome do protético" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
|
||||
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="Telefone (opcional)" className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm" />
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={criar} className="flex-1 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50">Cadastrar</button>
|
||||
<button onClick={() => setOpen(false)} className="px-3 py-2 rounded-lg border border-gray-200 text-gray-500 text-[11px] font-black uppercase">Cancelar</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-400 uppercase">Só da sua clínica · ele acompanha pelo link, sem criar conta.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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
|
||||
<option value="">Selecione...</option>
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
|
||||
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
|
||||
</div>
|
||||
{/* Produto (catálogo do laboratório — preço fixo) */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user