Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8c8fc0d2 | |||
| 7566c01159 | |||
| 87411e1566 | |||
| 29b5b38678 | |||
| 361f3809a7 | |||
| 4673f3c606 | |||
| dc5a2d89ae | |||
| 7671199420 | |||
| 6cd8c1f9e1 | |||
| c084ad553b | |||
| cd89ca245c | |||
| 07d798c370 | |||
| 6a88995146 | |||
| f5a6dc7ea0 |
+448
-38
@@ -3848,33 +3848,34 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||
const ids = rows.map(r => r.id);
|
||||
const notas = {};
|
||||
if (ids.length) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id] ?? null }));
|
||||
const notas = await notasScorePorProtetico(rows.map(r => r.id));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: internos primeiro, depois maior nota, depois nome.
|
||||
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
res.json(lista);
|
||||
} 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) => {
|
||||
@@ -4013,26 +4014,154 @@ app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
|
||||
const labId = await labDoProtetico(uid);
|
||||
const escopo = `(laboratorio_id = $2 OR (laboratorio_id IS NULL AND protetico_id = $1)) AND deleted_at IS NULL`;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT clinica_id, (SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome,
|
||||
`SELECT o.clinica_id, c.nome_fantasia AS clinica_nome, c.tipo AS clinica_tipo, u.role AS owner_role,
|
||||
u.celular AS contato, u.email AS contato_email,
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('entregue','cancelado'))::int AS ativas,
|
||||
COUNT(*) FILTER (WHERE prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado'))::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE status = 'entregue')::int AS entregues,
|
||||
COALESCE(SUM(custo) FILTER (WHERE status = 'entregue' AND tipo_os <> 'garantia'), 0) AS faturado
|
||||
FROM protese_os o WHERE ${escopo}
|
||||
GROUP BY clinica_id ORDER BY total DESC`, [uid, labId]);
|
||||
COUNT(*) FILTER (WHERE o.status NOT IN ('entregue','cancelado'))::int AS ativas,
|
||||
COUNT(*) FILTER (WHERE o.prazo_entrega IS NOT NULL AND o.prazo_entrega < CURRENT_DATE AND o.status NOT IN ('entregue','cancelado'))::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE o.status = 'entregue')::int AS entregues,
|
||||
COALESCE(SUM(o.custo) FILTER (WHERE o.status = 'entregue' AND o.tipo_os <> 'garantia'), 0) AS faturado,
|
||||
MAX(o.created_at) AS ultimo_pedido
|
||||
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id LEFT JOIN usuarios u ON u.id = c.owner_id
|
||||
WHERE ${escopo}
|
||||
GROUP BY o.clinica_id, c.nome_fantasia, c.tipo, u.role, u.celular, u.email ORDER BY total DESC`, [uid, labId]);
|
||||
const { rows: pagos } = await pool.query(
|
||||
`SELECT o.clinica_id, COALESCE(SUM(p.valor), 0) AS recebido
|
||||
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
|
||||
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
|
||||
GROUP BY o.clinica_id`, [uid, labId]);
|
||||
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
|
||||
const origemDe = (tipo, role) => tipo === 'consultorio' ? 'Consultório'
|
||||
: tipo === 'dentista' ? 'Dentista'
|
||||
: tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional')
|
||||
: 'Clínica';
|
||||
res.json(rows.map(r => {
|
||||
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
|
||||
return { ...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido) };
|
||||
return {
|
||||
...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido),
|
||||
origem: origemDe(r.clinica_tipo, r.owner_role),
|
||||
ticket_medio: r.entregues > 0 ? Math.round(faturado / r.entregues * 100) / 100 : 0,
|
||||
};
|
||||
}));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
// CRM financeiro do lab — fechamento mensal (faturado x recebido por mês).
|
||||
app.get('/api/protese/lab/fechamento', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoProtetico(uid);
|
||||
const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))`;
|
||||
const { rows: fat } = await pool.query(
|
||||
`SELECT to_char(o.updated_at,'YYYY-MM') AS mes, COALESCE(SUM(o.custo),0) AS faturado, COUNT(*)::int AS entregues
|
||||
FROM protese_os o WHERE o.status='entregue' AND o.tipo_os<>'garantia' AND o.updated_at IS NOT NULL AND ${esc} AND o.deleted_at IS NULL GROUP BY mes`, [uid, labId]);
|
||||
const { rows: rec } = await pool.query(
|
||||
`SELECT to_char(p.data,'YYYY-MM') AS mes, COALESCE(SUM(p.valor),0) AS recebido
|
||||
FROM protese_os_pagamento p JOIN protese_os o ON o.id=p.os_id WHERE p.lado='lab' AND ${esc} GROUP BY mes`, [uid, labId]);
|
||||
const meses = {};
|
||||
for (const f of fat) meses[f.mes] = { mes: f.mes, faturado: parseFloat(f.faturado || 0), entregues: f.entregues, recebido: 0 };
|
||||
for (const r of rec) { (meses[r.mes] = meses[r.mes] || { mes: r.mes, faturado: 0, entregues: 0, recebido: 0 }).recebido = parseFloat(r.recebido || 0); }
|
||||
res.json(Object.values(meses).sort((a, b) => b.mes.localeCompare(a.mes)));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 6 — OS indicadas a mim ainda SEM laboratório vinculado. Só o dono as vê (fallback);
|
||||
// ao importar, entram no lab e a equipe/técnicos passam a vê-las.
|
||||
app.get('/api/protese/lab/os-indicadas', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT o.id, o.tipo, o.status, o.custo, o.prazo_entrega, o.created_at, o.dentista_nome, o.paciente_nome,
|
||||
c.nome_fantasia AS cliente_nome, c.tipo AS cliente_tipo
|
||||
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id
|
||||
WHERE o.protetico_id=$1 AND o.laboratorio_id IS NULL AND o.deleted_at IS NULL
|
||||
AND o.status NOT IN ('entregue','cancelado')
|
||||
ORDER BY o.created_at DESC LIMIT 100`, [uid]);
|
||||
res.json(rows.map(r => ({ ...r, custo: parseFloat(r.custo || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 6 — Importar/vincular a OS ao MEU laboratório (formaliza a entrada).
|
||||
app.post('/api/protese/os/:id/vincular-lab', 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 });
|
||||
if (!acc.isLab) return res.status(403).json({ error: 'Apenas o protético destinatário importa a OS.' });
|
||||
if (acc.os.laboratorio_id) return res.status(409).json({ error: 'Esta OS já está vinculada a um laboratório.' });
|
||||
const labId = await labDoUsuario(uid);
|
||||
if (!labId) return res.status(400).json({ error: 'Você não possui laboratório.' });
|
||||
const novoStatus = acc.os.status === 'solicitado' ? 'recebido' : acc.os.status;
|
||||
await pool.query('UPDATE protese_os SET laboratorio_id=$1, status=$2 WHERE id=$3', [labId, novoStatus, acc.os.id]);
|
||||
await logEventoOS(acc.os, uid, 'Trabalho importado para o laboratório', 'vinculo');
|
||||
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Trabalho aceito pelo laboratório', `A OS (${acc.os.tipo}) foi importada pelo laboratório.`);
|
||||
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'vinculou' });
|
||||
res.json({ success: true, laboratorio_id: labId, status: novoStatus });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 6 — Solicitar pagamento (cobrar saldo): notifica o cliente e registra no histórico da OS.
|
||||
app.post('/api/protese/os/:id/cobrar', 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 });
|
||||
if (!acc.isLab) return res.status(403).json({ error: 'Apenas o laboratório solicita pagamento.' });
|
||||
const { rows: pg } = await pool.query("SELECT COALESCE(SUM(valor),0) AS pago FROM protese_os_pagamento WHERE os_id=$1 AND lado='lab'", [acc.os.id]);
|
||||
const pago = parseFloat(pg[0].pago || 0);
|
||||
const saldo = Math.max(0, parseFloat(acc.os.custo || 0) - pago);
|
||||
const valor = req.body?.valor != null ? Number(req.body.valor) : saldo;
|
||||
if (!(valor > 0)) return res.status(400).json({ error: 'Nada a cobrar: o saldo já está quitado.' });
|
||||
const brl = valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
const obs = (req.body?.observacao || '').trim();
|
||||
await logEventoOS(acc.os, uid, `Pagamento solicitado ao cliente: ${brl}${obs ? ` — ${obs}` : ''}`, 'cobranca');
|
||||
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Solicitação de pagamento', `O laboratório solicitou ${brl} referente à OS (${acc.os.tipo}).${obs ? ` ${obs}` : ''}`);
|
||||
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'cobranca' });
|
||||
res.json({ success: true, valor, saldo });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 6 — Dar entrada em OS pelo lado do LABORATÓRIO (trabalho recebido fora do app).
|
||||
// Cliente: clínica existente (cliente_id) OU cliente avulso (cliente_nome + cliente_tipo).
|
||||
app.post('/api/protese/lab/os', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoProtetico(uid);
|
||||
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório dá entrada em OS.' });
|
||||
const b = req.body || {};
|
||||
// Produto do catálogo (preço fixo) ou tipo livre + custo.
|
||||
let tipo = (b.tipo || '').trim() || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null;
|
||||
if (b.produto_id) {
|
||||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, uid]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence ao seu laboratório.' });
|
||||
produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12;
|
||||
}
|
||||
if (!tipo) return res.status(400).json({ error: 'Informe o produto ou o tipo de prótese.' });
|
||||
// Cliente: existente (valida acesso ao lab) ou avulso (materializa registro mínimo, owner nulo).
|
||||
let clienteId = b.cliente_id || null;
|
||||
if (clienteId) {
|
||||
const { rows: cl } = await pool.query('SELECT id FROM clinicas WHERE id=$1', [clienteId]);
|
||||
if (!cl.length) return res.status(404).json({ error: 'Cliente não encontrado.' });
|
||||
} else {
|
||||
const nome = (b.cliente_nome || '').trim();
|
||||
if (!nome) return res.status(400).json({ error: 'Informe o cliente (existente ou avulso).' });
|
||||
const tipoCli = ['clinica', 'consultorio', 'dentista'].includes(b.cliente_tipo) ? b.cliente_tipo : 'clinica';
|
||||
clienteId = `cliext_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,NULL)', [clienteId, nome.toUpperCase(), tipoCli]);
|
||||
}
|
||||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const status = b.local_atual === 'laboratorio' ? 'recebido' : 'recebido'; // já está com o lab
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_nome, dentista_nome, tipo, produto_id, tipo_os, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, local_atual, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'nova',$10,$11,$12,$13,$14,$15,0,$16,$17,'laboratorio',$18)`,
|
||||
[id, clienteId, uid, labId, (await actorNome(uid)) || null, b.paciente_nome || null, b.dentista_nome || null,
|
||||
tipo, produtoId, garantiaMeses, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
|
||||
b.prazo_entrega || null, custo, status, uid]);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'recebido',$4,$5,$6)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, clienteId, 'Entrada registrada pelo laboratório', uid, await actorNome(uid)]);
|
||||
res.json({ success: true, id, cliente_id: clienteId });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
|
||||
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
|
||||
@@ -4122,18 +4251,23 @@ async function calcularIndicadoresLab(uid, labId) {
|
||||
`SELECT 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 ${esc}`, [uid, labId]);
|
||||
const { rows: cp } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM protese_os_componente c JOIN protese_os o ON o.id = c.os_id WHERE c.status_conferencia='ausente' AND ${esc}`, [uid, labId]);
|
||||
const { rows: av } = await pool.query(
|
||||
`SELECT AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = (SELECT owner_id FROM clinicas WHERE id=$1) OR protetico_id = $2`, [labId, uid]);
|
||||
const r = a[0], entregues = r.entregues, pct = n => entregues > 0 ? (n / entregues * 100) : 0;
|
||||
const atraso_pct = pct(r.atrasados), retrab_pct = pct(r.garantia), ocor_pct = pct(oc[0].n);
|
||||
const satisf = av[0].n > 0 ? parseFloat(av[0].media) : null;
|
||||
let nota = null;
|
||||
if (entregues >= SCORE_MIN_OS) {
|
||||
nota = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
|
||||
nota = Math.round(nota * 10) / 10;
|
||||
const operacional = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
|
||||
nota = satisf != null ? 0.6 * operacional + 0.4 * (satisf / 5 * 10) : operacional;
|
||||
nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
||||
}
|
||||
const r1 = n => Math.round(Number(n) * 10) / 10;
|
||||
return {
|
||||
total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados,
|
||||
ocorrencias_resolvidas: oc[0].n, componentes_ausentes: cp[0].n, dias_medio: r1(r.dias_medio),
|
||||
atraso_pct: r1(atraso_pct), retrabalho_pct: r1(retrab_pct), ocorrencia_pct: r1(ocor_pct),
|
||||
satisfacao: satisf != null ? r1(satisf) : null, avaliacoes: av[0].n, verificado: entregues >= SCORE_VERIF_MIN,
|
||||
nota, min_os: SCORE_MIN_OS,
|
||||
};
|
||||
}
|
||||
@@ -4146,10 +4280,12 @@ 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.
|
||||
// Nota ScoreOdonto por protético (mapa id→{nota, avaliacoes, satisfacao, verificado}).
|
||||
// A nota combina o operacional (atraso/retrabalho/ocorrências) com a satisfação da clínica (estrelas).
|
||||
const SCORE_VERIF_MIN = 5; // entregas p/ selo "verificado"
|
||||
async function notasScorePorProtetico(ids) {
|
||||
const notas = {};
|
||||
if (!ids || !ids.length) return notas;
|
||||
const out = {};
|
||||
if (!ids || !ids.length) return out;
|
||||
const { rows: ag } = await pool.query(
|
||||
`SELECT protetico_id,
|
||||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
||||
@@ -4158,15 +4294,23 @@ async function notasScorePorProtetico(ids) {
|
||||
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 { rows: avs } = await pool.query(
|
||||
`SELECT protetico_id, AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = ANY($1) GROUP BY protetico_id`, [ids]);
|
||||
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
|
||||
const avMap = Object.fromEntries(avs.map(a => [a.protetico_id, { media: parseFloat(a.media), n: a.n }]));
|
||||
for (const g of ag) {
|
||||
const av = avMap[g.protetico_id];
|
||||
const info = { nota: null, avaliacoes: av?.n || 0, satisfacao: av ? Math.round(av.media * 10) / 10 : null, verificado: g.entregues >= SCORE_VERIF_MIN };
|
||||
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;
|
||||
const operacional = Math.max(0, Math.min(10, 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3)));
|
||||
// Com avaliações: 60% operacional + 40% satisfação (estrelas→0-10). Sem: só operacional.
|
||||
const nota = av && av.n > 0 ? 0.6 * operacional + 0.4 * (av.media / 5 * 10) : operacional;
|
||||
info.nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
||||
}
|
||||
out[g.protetico_id] = info;
|
||||
}
|
||||
return notas;
|
||||
return out;
|
||||
}
|
||||
|
||||
// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab).
|
||||
@@ -4211,6 +4355,177 @@ app.get('/api/protese/os/:id/link/status', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Fatia 3: Cotação (RFQ) — clínica pede, N labs cotam, clínica escolhe → vira OS ──
|
||||
app.post('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const b = req.body || {};
|
||||
const proteticos = Array.isArray(b.proteticos) ? b.proteticos.filter(Boolean) : [];
|
||||
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva o trabalho.' });
|
||||
if (!proteticos.length) return res.status(400).json({ error: 'Convide ao menos um laboratório.' });
|
||||
try {
|
||||
const uid = req.authUser?.userId;
|
||||
const rfqId = `rfq_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO protese_rfq (id, clinica_id, descricao, dentes, prazo_desejado, paciente_id, paciente_nome, dentista_id, dentista_nome, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[rfqId, req.clinicaId, String(b.descricao).trim(), b.dentes || null, b.prazo_desejado || null, b.paciente_id || null, b.paciente_nome || null, b.dentista_id || null, b.dentista_nome || null, uid]);
|
||||
let convidados = 0;
|
||||
for (const pid of proteticos) {
|
||||
if (!await proteticoAcessivelPelaClinica(pid, req.clinicaId)) continue;
|
||||
const { rows: pr } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [pid]);
|
||||
if (!pr.length) continue;
|
||||
await pool.query(`INSERT INTO protese_rfq_cotacao (id, rfq_id, protetico_id, protetico_nome) VALUES ($1,$2,$3,$4)`,
|
||||
[`cot_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${convidados}`, rfqId, pid, pr[0].nome]);
|
||||
await notificarUsuario(pid, 'Novo pedido de cotação', 'Você recebeu um pedido de cotação de prótese. Responda em Cotações.');
|
||||
convidados++;
|
||||
}
|
||||
res.json({ success: true, id: rfqId, convidados });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.get('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows: rfqs } = await pool.query("SELECT * FROM protese_rfq WHERE clinica_id=$1 ORDER BY created_at DESC LIMIT 100", [req.clinicaId]);
|
||||
const ids = rfqs.map(r => r.id);
|
||||
let cots = [];
|
||||
if (ids.length) { const { rows } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE rfq_id = ANY($1) ORDER BY (valor IS NULL), valor ASC", [ids]); cots = rows; }
|
||||
res.json(rfqs.map(r => ({ ...r, cotacoes: cots.filter(c => c.rfq_id === r.id).map(c => ({ ...c, valor: c.valor != null ? parseFloat(c.valor) : null })) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.get('/api/protese/rfq/recebidas', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.id AS cotacao_id, c.valor, c.prazo_dias, c.observacao, c.status AS cotacao_status,
|
||||
r.id AS rfq_id, r.descricao, r.dentes, r.prazo_desejado, r.paciente_nome, r.dentista_nome, r.status AS rfq_status, r.created_at,
|
||||
(SELECT nome_fantasia FROM clinicas WHERE id=r.clinica_id) AS clinica_nome
|
||||
FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id = c.rfq_id
|
||||
WHERE c.protetico_id=$1 ORDER BY r.created_at DESC LIMIT 100`, [req.authUser.userId]);
|
||||
res.json(rows.map(r => ({ ...r, valor: r.valor != null ? parseFloat(r.valor) : null })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/rfq/cotacao/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query("SELECT c.*, r.status AS rfq_status FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id=c.rfq_id WHERE c.id=$1", [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||||
if (rows[0].protetico_id !== req.authUser.userId) return res.status(403).json({ error: 'Não é sua cotação.' });
|
||||
if (rows[0].rfq_status !== 'aberta') return res.status(409).json({ error: 'Pedido de cotação encerrado.' });
|
||||
const valor = Number(req.body?.valor);
|
||||
if (!(valor > 0)) return res.status(400).json({ error: 'Informe o valor da cotação.' });
|
||||
await pool.query("UPDATE protese_rfq_cotacao SET valor=$1, prazo_dias=$2, observacao=$3, status='enviada', respondida_em=NOW() WHERE id=$4",
|
||||
[valor, parseInt(req.body?.prazo_dias, 10) || null, req.body?.observacao || null, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows: rr } = await pool.query("SELECT * FROM protese_rfq WHERE id=$1 AND clinica_id=$2", [req.params.id, req.clinicaId]);
|
||||
if (!rr.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||||
const rfq = rr[0];
|
||||
if (rfq.status !== 'aberta') return res.status(409).json({ error: 'Pedido já encerrado.' });
|
||||
const { rows: cc } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE id=$1 AND rfq_id=$2 AND status='enviada'", [req.body?.cotacao_id, rfq.id]);
|
||||
if (!cc.length) return res.status(400).json({ error: 'Cotação inválida.' });
|
||||
const cot = cc[0];
|
||||
const uid = req.authUser?.userId;
|
||||
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const labId = await labDoProtetico(cot.protetico_id);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, tipo, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,'solicitado',$15)`,
|
||||
[osId, req.clinicaId, cot.protetico_id, labId, cot.protetico_nome, rfq.paciente_id, rfq.paciente_nome, rfq.dentista_id, rfq.dentista_nome, rfq.descricao, rfq.dentes, rfq.descricao, rfq.prazo_desejado, parseFloat(cot.valor || 0), uid]);
|
||||
await pool.query(`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, req.clinicaId, 'OS criada a partir de cotação', uid, await actorNome(uid)]);
|
||||
await pool.query("UPDATE protese_rfq SET status='fechada', os_id=$1 WHERE id=$2", [osId, rfq.id]);
|
||||
await pool.query("UPDATE protese_rfq_cotacao SET status=CASE WHEN id=$1 THEN 'escolhida' ELSE 'recusada' END WHERE rfq_id=$2", [cot.id, rfq.id]);
|
||||
await notificarUsuario(cot.protetico_id, 'Cotação aceita!', 'Sua cotação foi escolhida — uma OS entrou na sua Bancada.');
|
||||
res.json({ success: true, os_id: osId });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Fatia 4 — a clínica avalia o laboratório ao final da OS (1-5 estrelas + comentário).
|
||||
app.post('/api/protese/os/:id/avaliar', 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 });
|
||||
if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica avalia.' });
|
||||
if (acc.os.status !== 'entregue') return res.status(409).json({ error: 'Avalie após a entrega.' });
|
||||
const estrelas = parseInt(req.body?.estrelas, 10);
|
||||
if (!(estrelas >= 1 && estrelas <= 5)) return res.status(400).json({ error: 'Estrelas de 1 a 5.' });
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_avaliacao (id, os_id, clinica_id, protetico_id, estrelas, comentario, autor_id, autor_nome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (os_id) DO UPDATE SET estrelas=$5, comentario=$6, autor_id=$7, autor_nome=$8, created_at=NOW()`,
|
||||
[`pav_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, acc.os.protetico_id, estrelas, req.body?.comentario || null, uid, nome]);
|
||||
await logEventoOS(acc.os, uid, `Laboratório avaliado: ${estrelas}★`, 'avaliacao');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Agenda & Coleta do laboratório ──────────────────────────────────────────
|
||||
app.post('/api/protese/os/:id/coleta', 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 tipo = ['coleta', 'entrega'].includes(req.body?.tipo) ? req.body.tipo : null;
|
||||
if (!tipo) return res.status(400).json({ error: "tipo deve ser 'coleta' ou 'entrega'." });
|
||||
const labId = acc.os.laboratorio_id || await labDoProtetico(acc.os.protetico_id);
|
||||
const id = `pcol_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const b = req.body || {};
|
||||
await pool.query(
|
||||
`INSERT INTO protese_coleta (id, os_id, clinica_id, laboratorio_id, protetico_id, tipo, data_hora, endereco, responsavel, observacao, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, labId, acc.os.protetico_id, tipo, b.data_hora || null, b.endereco || null, b.responsavel || null, b.observacao || null, uid]);
|
||||
const quando = b.data_hora ? new Date(b.data_hora).toLocaleString('pt-BR') : 'sem data';
|
||||
await logEventoOS(acc.os, uid, `${tipo === 'coleta' ? 'Coleta' : 'Entrega'} agendada (${quando})`, 'coleta');
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.get('/api/protese/lab/agenda', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoUsuario(uid);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.*, o.paciente_nome, o.tipo AS os_tipo, o.status AS os_status,
|
||||
(SELECT nome_fantasia FROM clinicas WHERE id=c.clinica_id) AS clinica_nome
|
||||
FROM protese_coleta c JOIN protese_os o ON o.id = c.os_id
|
||||
WHERE (c.laboratorio_id=$2 OR c.protetico_id=$1) AND c.status <> 'cancelada'
|
||||
ORDER BY c.status='realizada', c.data_hora ASC NULLS LAST LIMIT 200`, [uid, labId]);
|
||||
res.json(rows);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/coleta/:id/status', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_coleta WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Agendamento não encontrado.' });
|
||||
const col = rows[0];
|
||||
const acc = await carregaOSComAcesso(col.os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const novo = ['realizada', 'cancelada'].includes(req.body?.status) ? req.body.status : null;
|
||||
if (!novo) return res.status(400).json({ error: 'Status inválido.' });
|
||||
if (col.status !== 'agendada') return res.status(409).json({ error: `Já ${col.status}.` });
|
||||
await pool.query('UPDATE protese_coleta SET status=$1 WHERE id=$2', [novo, col.id]);
|
||||
if (novo === 'realizada') {
|
||||
// Coleta → trabalho no laboratório; entrega → trabalho na clínica. Move a custódia.
|
||||
const para = col.tipo === 'coleta' ? 'laboratorio' : 'clinica';
|
||||
const de = acc.os.local_atual || 'clinica';
|
||||
if (de !== para) {
|
||||
const nome = await actorNome(uid);
|
||||
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)}`, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, uid, nome]);
|
||||
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
|
||||
}
|
||||
await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} realizada`, 'coleta');
|
||||
} else {
|
||||
await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} cancelada`, 'coleta');
|
||||
}
|
||||
res.json({ success: true, status: novo });
|
||||
} 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 {
|
||||
@@ -4243,6 +4558,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]);
|
||||
@@ -4322,6 +4663,8 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [req.params.id]);
|
||||
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]);
|
||||
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: aval } = await pool.query('SELECT estrelas, comentario, autor_nome FROM protese_avaliacao WHERE os_id=$1', [req.params.id]);
|
||||
const { rows: coletas } = await pool.query('SELECT id, tipo, data_hora, endereco, responsavel, observacao, status FROM protese_coleta WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
// Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve.
|
||||
const soLab = isLab && !isClinic;
|
||||
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
|
||||
@@ -4335,6 +4678,8 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
componentes,
|
||||
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||||
custodia,
|
||||
avaliacao: aval[0] || null,
|
||||
coletas,
|
||||
ocorrencias: ocorrencias.map(o => ({
|
||||
...o,
|
||||
fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
@@ -6450,6 +6795,69 @@ async function runMigrations() {
|
||||
acessos INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`,
|
||||
// Marketplace transacional Fatia 3 — Cotação (RFQ): a clínica descreve um trabalho e N
|
||||
// laboratórios cotam (preço/prazo); a clínica escolhe e vira OS.
|
||||
`CREATE TABLE IF NOT EXISTS protese_rfq (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT NOT NULL,
|
||||
descricao TEXT NOT NULL,
|
||||
dentes TEXT,
|
||||
prazo_desejado DATE,
|
||||
paciente_id TEXT,
|
||||
paciente_nome TEXT,
|
||||
dentista_id TEXT,
|
||||
dentista_nome TEXT,
|
||||
status TEXT DEFAULT 'aberta',
|
||||
os_id TEXT,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_clinica ON protese_rfq (clinica_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_rfq_cotacao (
|
||||
id TEXT PRIMARY KEY,
|
||||
rfq_id TEXT NOT NULL,
|
||||
protetico_id TEXT NOT NULL,
|
||||
protetico_nome TEXT,
|
||||
valor NUMERIC,
|
||||
prazo_dias INTEGER,
|
||||
observacao TEXT,
|
||||
status TEXT DEFAULT 'convidada',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
respondida_em TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_rfq ON protese_rfq_cotacao (rfq_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`,
|
||||
// Fatia 4 — Avaliação da clínica ao final da OS (satisfação → alimenta a Nota; selo verificado).
|
||||
`CREATE TABLE IF NOT EXISTS protese_avaliacao (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL UNIQUE,
|
||||
clinica_id TEXT,
|
||||
protetico_id TEXT NOT NULL,
|
||||
estrelas INTEGER NOT NULL,
|
||||
comentario TEXT,
|
||||
autor_id TEXT,
|
||||
autor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_aval_prot ON protese_avaliacao (protetico_id)`,
|
||||
// Agenda & Coleta do laboratório: coleta (lab busca) / entrega (lab leva). 'realizada' move a custódia.
|
||||
`CREATE TABLE IF NOT EXISTS protese_coleta (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
laboratorio_id TEXT,
|
||||
protetico_id TEXT,
|
||||
tipo TEXT NOT NULL,
|
||||
data_hora TIMESTAMPTZ,
|
||||
endereco TEXT,
|
||||
responsavel TEXT,
|
||||
observacao TEXT,
|
||||
status TEXT DEFAULT 'agendada',
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
@@ -7212,7 +7620,9 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
|
||||
// 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 })));
|
||||
res.json(rows.map(r => r.role === 'protetico'
|
||||
? { ...r, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }
|
||||
: r));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agenda & Coleta do laboratório
|
||||
|
||||
> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de
|
||||
> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse).
|
||||
|
||||
## Conceito
|
||||
- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório).
|
||||
- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica).
|
||||
- Cada agendamento tem data/hora, endereço, responsável, observação e status
|
||||
(`agendada | realizada | cancelada`). Ligado a uma OS.
|
||||
- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente
|
||||
(coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado.
|
||||
|
||||
## Modelo
|
||||
```sql
|
||||
CREATE TABLE protese_coleta (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
laboratorio_id TEXT,
|
||||
protetico_id TEXT,
|
||||
tipo TEXT NOT NULL, -- coleta | entrega
|
||||
data_hora TIMESTAMPTZ,
|
||||
endereco TEXT,
|
||||
responsavel TEXT,
|
||||
observacao TEXT,
|
||||
status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab).
|
||||
- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo.
|
||||
- `POST /api/protese/coleta/:id/status` — `realizada` (move a custódia) | `cancelada`.
|
||||
- GET detalhe da OS passa a trazer `coletas[]`.
|
||||
|
||||
## UI
|
||||
- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas
|
||||
agendadas + entregas previstas (prazo da OS), com ação "marcar realizada".
|
||||
- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico).
|
||||
|
||||
## Decisões
|
||||
- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução.
|
||||
- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia
|
||||
e coleta em separado.
|
||||
@@ -24,19 +24,32 @@ via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica pass
|
||||
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
||||
prótese"** (proposta com contexto de prótese).
|
||||
|
||||
### Fatia 2 — Solicitação → OS direto
|
||||
"Solicitar prótese" abre um fluxo enxuto (produto do catálogo + paciente + dentes + prazo + obs) que
|
||||
**cria a OS** já com o lab selecionado (reusa `POST /api/protese/os`). Requer extrair o seletor de
|
||||
paciente/dentista da Nova OS para um componente reutilizável fora da tela de Prótese.
|
||||
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
|
||||
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
|
||||
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
|
||||
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
|
||||
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
|
||||
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
|
||||
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
|
||||
|
||||
### Fatia 3 — Cotação (RFQ) para trabalho sob medida
|
||||
Quando não há produto de catálogo: a clínica descreve o trabalho e **N labs cotam** (preço + prazo);
|
||||
a clínica escolhe e vira OS. Estende `propostas_profissional` com itens de prótese e valores, ou nova
|
||||
`protese_rfq` + `protese_rfq_cotacao`.
|
||||
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
|
||||
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
|
||||
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
|
||||
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
|
||||
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
|
||||
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
|
||||
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
|
||||
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
|
||||
|
||||
### Fatia 4 — Reputação pública e conversão
|
||||
Avaliação da clínica ao final da OS (alimenta a Nota, hoje só operacional), selo "lab verificado",
|
||||
ordenar marketplace por nota dentro do raio.
|
||||
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
|
||||
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
|
||||
reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem
|
||||
avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota ·
|
||||
nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST
|
||||
/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★,
|
||||
verificado, em marketplace e indicadores.
|
||||
|
||||
**Marketplace transacional concluído (Fatias 1-4).**
|
||||
|
||||
## Decisões de modelagem
|
||||
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.26 · commit `d357f3a`**.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.31 · commit `4673f3c`**.
|
||||
- **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.26 · `d357f3a` · 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.31 · `4673f3c` · 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.26**), 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.31**), 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.
|
||||
|
||||
+12
-2
@@ -32,7 +32,9 @@ import { ComissoesView } from './views/ComissoesView.tsx';
|
||||
import { GlosasView } from './views/GlosasView.tsx';
|
||||
import { SalaHomeView } from './views/SalaHomeView.tsx';
|
||||
import { LabHomeView } from './views/LabHomeView.tsx';
|
||||
import { LabAgendaView } from './views/LabAgendaView.tsx';
|
||||
import { LabClientesView } from './views/LabClientesView.tsx';
|
||||
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
|
||||
import { LabEquipeView } from './views/LabEquipeView.tsx';
|
||||
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
@@ -90,8 +92,10 @@ export type ViewKey =
|
||||
| 'sala-home'
|
||||
| 'lab-home'
|
||||
| 'lab-bancada'
|
||||
| 'lab-agenda'
|
||||
| 'lab-clientes'
|
||||
| 'lab-financeiro'
|
||||
| 'lab-cotacoes'
|
||||
| 'lab-equipe'
|
||||
| 'lab-indicadores'
|
||||
| 'marketplace-profissionais'
|
||||
@@ -146,8 +150,10 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/sala-home': 'sala-home',
|
||||
'/lab-home': 'lab-home',
|
||||
'/lab-bancada': 'lab-bancada',
|
||||
'/lab-agenda': 'lab-agenda',
|
||||
'/lab-clientes': 'lab-clientes',
|
||||
'/lab-financeiro': 'lab-financeiro',
|
||||
'/lab-cotacoes': 'lab-cotacoes',
|
||||
'/lab-equipe': 'lab-equipe',
|
||||
'/lab-indicadores': 'lab-indicadores',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
@@ -207,8 +213,10 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'sala-home': '/sala-home',
|
||||
'lab-home': '/lab-home',
|
||||
'lab-bancada': '/lab-bancada',
|
||||
'lab-agenda': '/lab-agenda',
|
||||
'lab-clientes': '/lab-clientes',
|
||||
'lab-financeiro': '/lab-financeiro',
|
||||
'lab-cotacoes': '/lab-cotacoes',
|
||||
'lab-equipe': '/lab-equipe',
|
||||
'lab-indicadores': '/lab-indicadores',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
@@ -310,7 +318,7 @@ const App: React.FC = () => {
|
||||
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
|
||||
if (view === 'plugins') return false;
|
||||
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
|
||||
@@ -329,7 +337,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: ['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-agenda', 'lab-clientes', 'lab-cotacoes', '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'],
|
||||
};
|
||||
|
||||
@@ -504,8 +512,10 @@ const App: React.FC = () => {
|
||||
case 'sala-home': return <SalaHomeView />;
|
||||
case 'lab-home': return <LabHomeView />;
|
||||
case 'lab-bancada': return <ProteseView />;
|
||||
case 'lab-agenda': return <LabAgendaView />;
|
||||
case 'lab-clientes': return <LabClientesView tab="clientes" />;
|
||||
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
|
||||
case 'lab-cotacoes': return <LabCotacoesView />;
|
||||
case 'lab-equipe': return <LabEquipeView />;
|
||||
case 'lab-indicadores': return <LabIndicadoresView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
|
||||
@@ -47,7 +47,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
|
||||
}
|
||||
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
|
||||
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
|
||||
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
|
||||
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
|
||||
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
|
||||
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
|
||||
}
|
||||
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
|
||||
@@ -63,7 +65,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
|
||||
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
{ id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
|
||||
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
|
||||
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
|
||||
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
|
||||
@@ -104,7 +108,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
|
||||
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
|
||||
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
|
||||
if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
if (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
@@ -117,7 +121,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
|
||||
return ehTecnico
|
||||
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
|
||||
: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
}
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
|
||||
@@ -1095,6 +1095,29 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getLabFechamento: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
// Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório.
|
||||
getLabOsIndicadas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
vincularOsLab: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: solicitar pagamento (cobrar saldo) ao cliente.
|
||||
cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso).
|
||||
criarLabOs: async (body: any) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
||||
getLabDados: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`);
|
||||
@@ -1124,6 +1147,27 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
// Agenda & Coleta do laboratório
|
||||
agendarColeta: async (osId: string, body: { tipo: 'coleta' | 'entrega'; data_hora?: string; endereco?: string; responsavel?: string; observacao?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/coleta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao agendar');
|
||||
return await res.json();
|
||||
},
|
||||
getLabAgenda: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/agenda`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
statusColeta: async (coletaId: string, status: 'realizada' | 'cancelada') => {
|
||||
const res = await apiFetch(`${API_URL}/protese/coleta/${coletaId}/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 atualizar');
|
||||
return await res.json();
|
||||
},
|
||||
// Fatia 4: a clínica avalia o laboratório ao final da OS
|
||||
avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avaliar');
|
||||
return await res.json();
|
||||
},
|
||||
// Modo 1: link seguro de acompanhamento da OS
|
||||
gerarProteseLink: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
|
||||
@@ -1134,17 +1178,56 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
|
||||
return await res.json().catch(() => ({ ativo: false }));
|
||||
},
|
||||
// Cotação (RFQ) — Fatia 3
|
||||
criarRfq: async (body: any) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?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 pedir cotação');
|
||||
return await res.json();
|
||||
},
|
||||
getRfqs: async (): Promise<any[]> => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getRfqRecebidas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/recebidas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
responderCotacao: async (cotacaoId: string, body: { valor: number; prazo_dias?: number; observacao?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/cotacao/${cotacaoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cotar');
|
||||
return await res.json();
|
||||
},
|
||||
escolherCotacao: async (rfqId: string, cotacaoId: string) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/${rfqId}/escolher?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cotacao_id: cotacaoId }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao escolher');
|
||||
return await res.json();
|
||||
},
|
||||
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();
|
||||
},
|
||||
// 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 }) });
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||
const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); };
|
||||
|
||||
// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas.
|
||||
export const LabAgendaView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); }
|
||||
catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const acao = async (id: string, status: 'realizada' | 'cancelada') => {
|
||||
setBusy(id);
|
||||
try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
||||
};
|
||||
|
||||
// agrupa por dia (data_hora)
|
||||
const grupos: Record<string, any[]> = {};
|
||||
for (const c of lista.filter(x => x.status === 'agendada')) {
|
||||
const k = (c.data_hora || '').slice(0, 10) || 'sem-data';
|
||||
(grupos[k] = grupos[k] || []).push(c);
|
||||
}
|
||||
const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10);
|
||||
|
||||
const Card: React.FC<{ c: any }> = ({ c }) => {
|
||||
const coleta = c.tipo === 'coleta';
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 ${coleta ? 'bg-violet-600' : 'bg-blue-600'}`}>{coleta ? <Truck size={18} /> : <PackageCheck size={18} />}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${coleta ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{coleta ? 'Coleta' : 'Entrega'}</span>
|
||||
<span className="text-[11px] font-black text-gray-500">{dataHoraBR(c.data_hora)}</span>
|
||||
</div>
|
||||
<p className="font-bold text-gray-800 text-sm truncate mt-1">{c.os_tipo} {c.paciente_nome ? <span className="text-gray-400 font-medium">· {c.paciente_nome}</span> : ''}</p>
|
||||
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}</p>
|
||||
{c.endereco && <p className="text-[11px] text-gray-400 flex items-center gap-1 mt-0.5"><MapPin size={11} /> {c.endereco}</p>}
|
||||
{c.responsavel && <p className="text-[11px] text-gray-400 flex items-center gap-1"><User size={11} /> {c.responsavel}</p>}
|
||||
{c.status === 'agendada' && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button disabled={busy === c.id} onClick={() => acao(c.id, 'realizada')} className="px-3 py-1.5 rounded-lg bg-[#2d6a4f] text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><CheckCircle2 size={12} /> Realizada</button>
|
||||
<button disabled={busy === c.id} onClick={() => acao(c.id, 'cancelada')} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-[10px] font-black uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="AGENDA DE COLETAS">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
||||
: lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma coleta ou entrega agendada.</div>
|
||||
: (
|
||||
<div className="space-y-5">
|
||||
{Object.keys(grupos).sort().map(dia => (
|
||||
<div key={dia}>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><CalendarClock size={12} /> {dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}</p>
|
||||
<div className="space-y-2">{grupos[dia].map(c => <Card key={c.id} c={c} />)}</div>
|
||||
</div>
|
||||
))}
|
||||
{realizadas.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-2">Realizadas (recentes)</p>
|
||||
<div className="space-y-2 opacity-60">{realizadas.map(c => <Card key={c.id} c={c} />)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,35 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.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 mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : m; };
|
||||
|
||||
// Área do Laboratório (Fase 3): Clientes (clínicas que enviam OS) + Financeiro (a-receber).
|
||||
// Uma view, duas abas — o item de menu define a aba inicial.
|
||||
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
|
||||
'Clínica': { icon: Building2, cls: 'bg-blue-100 text-blue-700' },
|
||||
'Consultório': { icon: Briefcase, cls: 'bg-teal-100 text-teal-700' },
|
||||
'Dentista': { icon: Stethoscope, cls: 'bg-violet-100 text-violet-700' },
|
||||
'Biomédico': { icon: User, cls: 'bg-pink-100 text-pink-700' },
|
||||
'Profissional': { icon: User, cls: 'bg-gray-100 text-gray-600' },
|
||||
};
|
||||
|
||||
// CRM Financeiro do laboratório: Clientes (por origem, com contato/ticket/recebíveis) + Financeiro
|
||||
// (fechamento mensal + recebimentos).
|
||||
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
|
||||
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [pagamentos, setPagamentos] = useState<any[]>([]);
|
||||
const [fechamento, setFechamento] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setAba(tab); }, [tab]);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, p] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos()]);
|
||||
setClientes(Array.isArray(c) ? c : []);
|
||||
setPagamentos(Array.isArray(p) ? p : []);
|
||||
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
|
||||
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
@@ -29,6 +37,9 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
||||
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
|
||||
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
|
||||
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
|
||||
// resumo por origem
|
||||
const porOrigem: Record<string, number> = {};
|
||||
for (const c of clientes) porOrigem[c.origem] = (porOrigem[c.origem] || 0) + 1;
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
@@ -41,41 +52,50 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex border-b border-gray-100">
|
||||
{(['clientes', 'financeiro'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes' : 'Financeiro'}</button>
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
{aba === 'clientes' && (
|
||||
clientes.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Clínica', 'OS', 'Ativas', 'Atrasadas', 'Entregues', 'Faturado', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{clientes.map(c => (
|
||||
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase flex items-center gap-2"><Building2 size={14} className="text-teal-600 shrink-0" /> {c.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
|
||||
<td className="px-4 py-3 text-teal-600 font-bold">{c.ativas}</td>
|
||||
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-gray-400'}`}>{c.atrasadas}</td>
|
||||
<td className="px-4 py-3 text-green-600 font-bold">{c.entregues}</td>
|
||||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(c.faturado)}</td>
|
||||
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
clientes.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div> : (
|
||||
<>
|
||||
{/* resumo por origem */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(porOrigem).map(([o, n]) => {
|
||||
const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon;
|
||||
return <span key={o} className={`text-[10px] font-black px-3 py-1.5 rounded-full uppercase flex items-center gap-1.5 ${m.cls}`}><I size={12} /> {o}: {n}</span>;
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{clientes.map(c => {
|
||||
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
|
||||
return (
|
||||
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase">{c.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3"><span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${m.cls}`}>{c.origem}</span></td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{c.contato ? <span className="flex items-center gap-1"><Phone size={11} /> {c.contato}</span> : '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
|
||||
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{fmtBRL(c.ticket_medio)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'}</td>
|
||||
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -86,34 +106,49 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
||||
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
|
||||
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
|
||||
</div>
|
||||
|
||||
{/* Fechamento mensal */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
<Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length} lançamento(s)</span>
|
||||
</div>
|
||||
{pagamentos.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento registrado.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Clínica', 'Paciente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagamentos.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{p.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
|
||||
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Fechamento mensal</h3></div>
|
||||
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
|
||||
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fechamento.map(m => (
|
||||
<tr key={m.mes} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase">{mesBR(m.mes)}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{m.entregues}</td>
|
||||
<td className="px-4 py-3 font-bold text-gray-800">{fmtBRL(m.faturado)}</td>
|
||||
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(m.recebido)}</td>
|
||||
<td className={`px-4 py-3 font-black ${m.faturado - m.recebido > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Faturado = custo das OS entregues (exceto garantia). A receber = faturado − recebido.</p>
|
||||
|
||||
{/* Extrato */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3><span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length}</span></div>
|
||||
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
|
||||
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagamentos.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
|
||||
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues (≠ garantia). A receber = faturado − recebido.</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
|
||||
export const LabCotacoesView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
|
||||
catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
|
||||
const responder = async (c: any) => {
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
const valor = parseFloat(f.valor);
|
||||
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
|
||||
setBusy(c.cotacao_id);
|
||||
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="PEDIDOS DE COTAÇÃO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
||||
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
{lista.map(c => {
|
||||
const aberta = c.rfq_status === 'aberta';
|
||||
const ganhou = c.cotacao_status === 'escolhida';
|
||||
const perdeu = c.cotacao_status === 'recusada';
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
return (
|
||||
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
|
||||
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
|
||||
</div>
|
||||
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
|
||||
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
|
||||
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
|
||||
</div>
|
||||
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
|
||||
|
||||
{aberta && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
|
||||
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
|
||||
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} 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"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
|
||||
</div>
|
||||
)}
|
||||
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,51 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const CLI_TIPO = [
|
||||
{ v: 'clinica', label: 'Clínica', icon: Building2 },
|
||||
{ v: 'consultorio', label: 'Consultório', icon: Briefcase },
|
||||
{ v: 'dentista', label: 'Dentista', icon: Stethoscope },
|
||||
];
|
||||
|
||||
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
|
||||
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
|
||||
export const LabHomeView: React.FC = () => {
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
const toast = useToast();
|
||||
const [os, setOs] = useState<any[]>([]);
|
||||
const [fin, setFin] = useState<any[]>([]);
|
||||
const [indicadas, setIndicadas] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEntrada, setShowEntrada] = useState(false);
|
||||
const [importando, setImportando] = useState<string | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getFinanceiro()]);
|
||||
const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]);
|
||||
setOs(Array.isArray(b) ? b : []);
|
||||
setFin(Array.isArray(f) ? f : []);
|
||||
setIndicadas(Array.isArray(ind) ? ind : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const importar = async (osId: string) => {
|
||||
setImportando(osId);
|
||||
try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); }
|
||||
};
|
||||
|
||||
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
|
||||
const atrasadas = os.filter(o => o.atrasada).length;
|
||||
const ymMes = new Date().toISOString().slice(0, 7);
|
||||
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
|
||||
// Faturamento do mês: RECEITA de prótese lançada no workspace do lab (espelho da entrega).
|
||||
const fatMes = fin
|
||||
.filter(l => l.origem === 'protese' && (l.data_competencia || l.datavencimento || '').slice(0, 7) === ymMes)
|
||||
.reduce((s, l) => s + Number(l.valor || 0), 0);
|
||||
// Faturamento do mês: do fechamento do próprio lab (custo das OS entregues no mês).
|
||||
const fatMes = Number(fin.find(m => m.mes === ymMes)?.faturado || 0);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
@@ -49,6 +64,9 @@ export const LabHomeView: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="PAINEL DO LABORATÓRIO">
|
||||
<button onClick={() => setShowEntrada(true)} className="text-white px-3 py-2 rounded-lg flex items-center gap-2 text-xs font-black uppercase shadow-sm" style={{ backgroundColor: '#2d6a4f' }}>
|
||||
<Plus size={16} /> <span className="hidden sm:inline">Dar entrada</span>
|
||||
</button>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
@@ -71,6 +89,31 @@ export const LabHomeView: React.FC = () => {
|
||||
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
|
||||
</div>
|
||||
|
||||
{/* OS indicadas ao laboratório aguardando importação */}
|
||||
{indicadas.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-amber-100 bg-amber-50/60 flex items-center gap-2">
|
||||
<ArrowDownToLine size={16} className="text-amber-600" />
|
||||
<h3 className="text-sm font-black text-amber-800 uppercase">OS indicadas a você ({indicadas.length})</h3>
|
||||
<span className="text-[10px] font-bold text-amber-500 uppercase ml-auto hidden sm:inline">Importe para entrar na produção e financeiro</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{indicadas.map(o => (
|
||||
<div key={o.id} className="px-6 py-3 flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-gray-800 uppercase truncate text-sm">{o.tipo}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}</p>
|
||||
</div>
|
||||
<span className="text-sm font-black text-gray-700 hidden sm:block">{fmtBRL(o.custo)}</span>
|
||||
<button disabled={importando === o.id} onClick={() => importar(o.id)} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5 shrink-0">
|
||||
<ArrowDownToLine size={13} /> Importar
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fila por status */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
@@ -119,6 +162,101 @@ export const LabHomeView: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEntrada && <LabEntradaModal onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: dar entrada em OS pelo lado do laboratório ────────────────────────
|
||||
const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// origem do cliente: existente ou avulso
|
||||
const [clienteId, setClienteId] = useState(''); // '' = avulso
|
||||
const [cliNome, setCliNome] = useState('');
|
||||
const [cliTipo, setCliTipo] = useState('clinica');
|
||||
const [produtoId, setProdutoId] = useState(''); // '' = tipo livre
|
||||
const [tipo, setTipo] = useState('');
|
||||
const [custo, setCusto] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [paciente, setPaciente] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {});
|
||||
HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const salvar = async () => {
|
||||
if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; }
|
||||
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: any = { paciente_nome: paciente.trim() || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
|
||||
if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; }
|
||||
if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; }
|
||||
const r = await HybridBackend.criarLabOs(body);
|
||||
if (r?.error) throw new Error(r.error);
|
||||
toast.success('ENTRADA REGISTRADA.');
|
||||
onSaved();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide';
|
||||
const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> Dar entrada em OS</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Cliente */}
|
||||
<div>
|
||||
<label className={lbl}>Cliente</label>
|
||||
<select value={clienteId} onChange={e => setClienteId(e.target.value)} className={inp}>
|
||||
<option value="">➕ Novo cliente avulso…</option>
|
||||
{clientes.map(c => <option key={c.clinica_id} value={c.clinica_id}>{c.clinica_nome} ({c.origem})</option>)}
|
||||
</select>
|
||||
{!clienteId && (
|
||||
<div className="mt-2 space-y-2 bg-gray-50 rounded-xl p-3">
|
||||
<input value={cliNome} onChange={e => setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} />
|
||||
<div className="flex gap-2">
|
||||
{CLI_TIPO.map(t => { const I = t.icon; return (
|
||||
<button key={t.v} onClick={() => setCliTipo(t.v)} className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1 border ${cliTipo === t.v ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-200 text-gray-400'}`}><I size={12} /> {t.label}</button>
|
||||
); })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Produto */}
|
||||
<div>
|
||||
<label className={lbl}>Produto</label>
|
||||
<select value={produtoId} onChange={e => setProdutoId(e.target.value)} className={inp}>
|
||||
<option value="">✏️ Tipo livre…</option>
|
||||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(Number(p.preco || 0))}</option>)}
|
||||
</select>
|
||||
{!produtoId && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input value={tipo} onChange={e => setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} />
|
||||
<input value={custo} onChange={e => setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
|
||||
<div><label className={lbl}>Paciente (opcional)</label><input value={paciente} onChange={e => setPaciente(e.target.value)} placeholder="Nome" className={inp} /></div>
|
||||
</div>
|
||||
<div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div>
|
||||
<button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra já com o laboratório (custódia) e no seu financeiro.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-react';
|
||||
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2, BadgeCheck, Star } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
@@ -41,9 +41,12 @@ export const LabIndicadoresView: React.FC = () => {
|
||||
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
||||
{ind.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5"><BadgeCheck size={11} /> Verificado</span>}
|
||||
</div>
|
||||
{temNota ? (
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Sobre {ind.entregues} entrega(s){ind.avaliacoes ? ` · ${ind.satisfacao}★ de ${ind.avaliacoes} avaliações` : ''}. Combina desempenho (atraso/retrabalho/ocorrências) e satisfação da clínica.</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação — mínimo de {ind.min_os} entregas para gerar a nota.</p>
|
||||
)}
|
||||
@@ -57,6 +60,7 @@ export const LabIndicadoresView: React.FC = () => {
|
||||
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
|
||||
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
|
||||
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
|
||||
<KPI titulo="Satisfação" valor={ind.satisfacao != null ? `${ind.satisfacao}★` : '—'} cor="text-amber-500" Icon={Star} sub={`${ind.avaliacoes || 0} avaliação(ões)`} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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, Share2 } 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, FileText, Star } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -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>
|
||||
@@ -372,6 +404,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||
)}
|
||||
{os.status === 'entregue' && modo === 'clinica' && <AvaliacaoSecao os={os} onChanged={carregar} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -386,6 +419,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
{tab === 'monitor' && (
|
||||
<div className="space-y-5">
|
||||
<MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />
|
||||
<AgendaColetaSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
{modo === 'clinica' && <CompartilharLinkSecao os={os} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -599,6 +633,73 @@ const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Agendar coleta / entrega (integra com a custódia) ────────────────
|
||||
const AgendaColetaSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [tipo, setTipo] = useState<'coleta' | 'entrega'>('coleta');
|
||||
const [data, setData] = useState('');
|
||||
const [resp, setResp] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.agendarColeta(os.id, { tipo, data_hora: data || undefined, responsavel: resp.trim() || undefined }); toast.success('AGENDADO.'); setData(''); setResp(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const marcar = async (id: string, status: 'realizada' | 'cancelada') => { try { await HybridBackend.statusColeta(id, status); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
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"><Clock size={12} /> Coleta / entrega</p>
|
||||
{(os.coletas || []).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.tipo === 'coleta' ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{c.tipo}</span>
|
||||
<span className="text-gray-600 flex-1 truncate">{c.data_hora ? dataHoraBR(c.data_hora) : 'sem data'}{c.responsavel ? ` · ${c.responsavel}` : ''}</span>
|
||||
{c.status === 'agendada'
|
||||
? (podeEditar && <button onClick={() => marcar(c.id, 'realizada')} className="text-[9px] font-black text-green-600 uppercase">Realizar</button>)
|
||||
: <span className={`text-[8px] font-black uppercase ${c.status === 'realizada' ? 'text-green-600' : 'text-gray-400'}`}>{c.status}</span>}
|
||||
</div>
|
||||
))}
|
||||
{(os.coletas || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma coleta agendada.</p>}
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={tipo} onChange={e => setTipo(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
|
||||
<option value="coleta">Coleta</option>
|
||||
<option value="entrega">Entrega</option>
|
||||
</select>
|
||||
<input type="datetime-local" value={data} onChange={e => setData(e.target.value)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] outline-none focus:border-teal-400" />
|
||||
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável" className="flex-1 min-w-[100px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Avaliar o laboratório (após a entrega, lado clínica) ─────────────
|
||||
const AvaliacaoSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [estrelas, setEstrelas] = useState<number>(os.avaliacao?.estrelas || 0);
|
||||
const [coment, setComent] = useState<string>(os.avaliacao?.comentario || '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const salvar = async () => {
|
||||
if (!estrelas) { toast.error('DÊ UMA NOTA DE 1 A 5.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.avaliarProteseOS(os.id, { estrelas, comentario: coment.trim() || undefined }); toast.success('AVALIAÇÃO ENVIADA.'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div className="pt-3 border-t border-gray-100">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Star size={12} /> Avaliar o laboratório</p>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button key={n} onClick={() => setEstrelas(n)} className={n <= estrelas ? 'text-amber-400' : 'text-gray-200'}><Star size={26} fill={n <= estrelas ? 'currentColor' : 'none'} /></button>
|
||||
))}
|
||||
</div>
|
||||
<textarea value={coment} onChange={e => setComent(e.target.value)} rows={2} placeholder="Comentário (opcional)" className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-amber-400 resize-none" />
|
||||
<button disabled={busy} onClick={salvar} className="mt-2 px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">{os.avaliacao ? 'Atualizar avaliação' : 'Enviar avaliação'}</button>
|
||||
</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';
|
||||
@@ -774,6 +875,13 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProtesePagamento(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
// Solicitar pagamento (cobrança) do saldo devedor — apenas o laboratório.
|
||||
const saldoLab = Math.max(0, Number(os.custo || 0) - totalLab);
|
||||
const cobrar = async () => {
|
||||
setBusy(true);
|
||||
try { const r = await HybridBackend.cobrarOs(os.id); if (r?.error) throw new Error(r.error); toast.success('SOLICITAÇÃO ENVIADA AO CLIENTE.'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
|
||||
@@ -808,6 +916,12 @@ const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEdit
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
{/* Solicitar pagamento (cobrança) — só o laboratório, quando há saldo devedor. */}
|
||||
{modo === 'bancada' && podeEditar && saldoLab > 0 && (
|
||||
<button disabled={busy} onClick={cobrar} className="mt-2 w-full py-2 rounded-lg border border-amber-300 bg-amber-50 text-amber-700 text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-1.5">
|
||||
<Wallet size={13} /> Solicitar pagamento ({fmtBRL(saldoLab)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -885,6 +999,103 @@ const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
};
|
||||
|
||||
// ── View principal ──────────────────────────────────────────────────────────
|
||||
// ── Modal: Cotações (lado clínica) — pedir a N labs, comparar e escolher ────
|
||||
const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => void }> = ({ onClose, onEscolhida }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [aba, setAba] = useState<'novo' | 'lista'>('lista');
|
||||
const [rfqs, setRfqs] = useState<any[]>([]);
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [descricao, setDescricao] = useState('');
|
||||
const [dentes, setDentes] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
|
||||
useEffect(() => { carregar(); HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); }, [carregar]);
|
||||
|
||||
const toggleLab = (id: string) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
|
||||
const criar = async () => {
|
||||
if (!descricao.trim()) { toast.error('DESCREVA O TRABALHO.'); return; }
|
||||
if (!sel.length) { toast.error('SELECIONE AO MENOS UM LABORATÓRIO.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.criarRfq({ descricao: descricao.trim(), dentes: dentes.trim() || undefined, prazo_desejado: prazo || undefined, proteticos: sel }); toast.success('COTAÇÃO ENVIADA AOS LABORATÓRIOS.'); setDescricao(''); setDentes(''); setPrazo(''); setSel([]); setAba('lista'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const escolher = async (rfq: any, cot: any) => {
|
||||
if (!(await confirm(`ESCOLHER ${cot.protetico_nome} POR ${fmtBRL(cot.valor)}? UMA OS SERÁ CRIADA.`))) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.escolherCotacao(rfq.id, cot.id); toast.success('OS CRIADA A PARTIR DA COTAÇÃO.'); carregar(); onEscolhida(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="flex border-b border-gray-100 px-2">
|
||||
{(['lista', 'novo'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{aba === 'novo' ? (
|
||||
<div className="space-y-3">
|
||||
<textarea value={descricao} onChange={e => setDescricao(e.target.value)} rows={3} placeholder="Descreva o trabalho (ex.: Coroa sobre implante, dente 36, cor A2)" className="w-full p-3 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400 resize-none" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={dentes} onChange={e => setDentes(e.target.value)} placeholder="Dentes (ex.: 36)" className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
<input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||
{labs.map(l => (
|
||||
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
|
||||
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
|
||||
{l.interno && <span className="text-[8px] font-black text-teal-600 uppercase">interno</span>}
|
||||
{l.nota != null && <span className="text-[9px] font-black text-amber-600">★ {l.nota}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={criar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50">Enviar pedido a {sel.length} laboratório(s)</button>
|
||||
</div>
|
||||
) : (
|
||||
rfqs.length === 0 ? <p className="text-center text-gray-300 font-bold uppercase text-sm py-12">Nenhum pedido de cotação ainda.</p> : (
|
||||
<div className="space-y-3">
|
||||
{rfqs.map(r => (
|
||||
<div key={r.id} className="border border-gray-100 rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-black text-gray-800 text-sm">{r.descricao}</p>
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${r.status === 'fechada' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>{r.status === 'fechada' ? 'fechada' : 'aberta'}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{(r.cotacoes || []).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="font-bold text-gray-700 flex-1 truncate">{c.protetico_nome}</span>
|
||||
{c.status === 'convidada' ? <span className="text-[10px] text-gray-400 uppercase">aguardando…</span>
|
||||
: <><span className="font-black text-gray-800">{fmtBRL(c.valor)}</span>{c.prazo_dias ? <span className="text-[10px] text-gray-400">{c.prazo_dias}d</span> : null}</>}
|
||||
{c.status === 'escolhida' && <span className="text-[8px] font-black text-green-700 uppercase">escolhida</span>}
|
||||
{r.status === 'aberta' && c.status === 'enviada' && <button disabled={busy} onClick={() => escolher(r, c)} className="px-2 py-1 rounded-lg bg-[#2d6a4f] text-white text-[9px] font-black uppercase disabled:opacity-50">Escolher</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProteseView: React.FC = () => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
|
||||
@@ -896,6 +1107,7 @@ export const ProteseView: React.FC = () => {
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
const [detalhe, setDetalhe] = useState<string | null>(null);
|
||||
const [produtosModal, setProdutosModal] = useState(false);
|
||||
const [cotacoesOpen, setCotacoesOpen] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -923,9 +1135,10 @@ export const ProteseView: React.FC = () => {
|
||||
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
|
||||
>
|
||||
{modo === 'clinica' ? (
|
||||
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
|
||||
<Plus size={16} /> Nova OS
|
||||
</button>
|
||||
<>
|
||||
<button onClick={() => setCotacoesOpen(true)} className="flex items-center gap-2 border border-violet-200 text-violet-700 px-4 py-2 rounded-xl font-black text-sm uppercase hover:bg-violet-50"><FileText size={16} /> Cotações</button>
|
||||
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Plus size={16} /> Nova OS</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
|
||||
@@ -986,6 +1199,7 @@ export const ProteseView: React.FC = () => {
|
||||
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
|
||||
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
|
||||
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
|
||||
{cotacoesOpen && <CotacoesClinicaModal onClose={() => setCotacoesOpen(false)} onEscolhida={() => carregar()} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,112 @@ import {
|
||||
|
||||
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
|
||||
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
const [paciente, setPaciente] = useState<any>(null);
|
||||
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [okMsg, setOkMsg] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [prof.id, ws?.id]);
|
||||
useEffect(() => {
|
||||
if (paciente) return;
|
||||
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [busca, paciente]);
|
||||
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
||||
|
||||
const salvar = async () => {
|
||||
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
|
||||
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||
setSaving(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
...form, protetico_id: prof.id, valor: 0,
|
||||
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
|
||||
});
|
||||
setOkMsg(true);
|
||||
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
{okMsg ? (
|
||||
<div className="p-8 text-center space-y-2">
|
||||
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
|
||||
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
|
||||
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
|
||||
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
|
||||
{produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Escolha o trabalho…</option>
|
||||
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} — {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
||||
{paciente ? (
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
|
||||
) : (
|
||||
<div className="relative mt-1">
|
||||
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
||||
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
{busca && pacientes.length > 0 && (
|
||||
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
||||
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">—</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
|
||||
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
|
||||
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
|
||||
</div>
|
||||
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
||||
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
||||
const [aberto, setAberto] = useState(false);
|
||||
@@ -81,6 +187,8 @@ interface Profissional {
|
||||
bio_profissional?: string | null;
|
||||
dist_km?: number | string | null;
|
||||
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
|
||||
verificado?: boolean; // selo (≥ 5 entregas)
|
||||
avaliacoes?: number; // nº de avaliações da clínica
|
||||
}
|
||||
|
||||
interface Proposta {
|
||||
@@ -443,6 +551,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
// propostas
|
||||
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
||||
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
||||
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
|
||||
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
||||
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
||||
|
||||
@@ -736,7 +845,8 @@ 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>}
|
||||
{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}{p.avaliacoes ? ` · ${p.avaliacoes}` : ''}</span>}
|
||||
{p.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5" title="Laboratório verificado"><BadgeCheck size={10} /> Verificado</span>}
|
||||
</div>
|
||||
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
|
||||
</div>
|
||||
@@ -761,7 +871,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
</div>
|
||||
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
||||
{ws?.id && (
|
||||
<button onClick={() => setProposingTo(p)}
|
||||
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
|
||||
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
||||
</button>
|
||||
@@ -932,6 +1042,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
{tab === 'modelos' && <ModelosManager />}
|
||||
|
||||
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
||||
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user