Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d83e0c82b2 | |||
| e6d8b532e3 | |||
| 8353a9d897 | |||
| 7c8c8fc0d2 | |||
| 7566c01159 | |||
| 87411e1566 | |||
| 29b5b38678 | |||
| 361f3809a7 |
+196
-16
@@ -3839,23 +3839,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.nome, u.email,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno
|
||||
`SELECT u.id, u.nome, u.email, u.celular,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno,
|
||||
BOOL_OR(pf.favorito) AS favorito,
|
||||
BOOL_OR(pf.padrao) AS padrao
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
|
||||
WHERE u.role = 'protetico'
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
|
||||
GROUP BY u.id, u.nome, u.email, u.celular
|
||||
ORDER BY u.nome`, [clinicaId]);
|
||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||
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));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: padrão → favorito → interno → maior nota → nome.
|
||||
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (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 }); }
|
||||
});
|
||||
|
||||
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
|
||||
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const protId = req.params.proteticoId;
|
||||
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
const favorito = req.body?.favorito;
|
||||
const padrao = req.body?.padrao;
|
||||
// Só pode haver UM padrão por clínica.
|
||||
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
|
||||
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
|
||||
await pool.query(
|
||||
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
|
||||
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
|
||||
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
|
||||
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
|
||||
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
|
||||
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const email = (req.body?.email || '').toLowerCase().trim();
|
||||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||||
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
|
||||
const p = rows[0];
|
||||
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_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
|
||||
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
|
||||
} 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.
|
||||
@@ -4014,26 +4057,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) => {
|
||||
@@ -6730,6 +6901,15 @@ async function runMigrations() {
|
||||
)`,
|
||||
`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)`,
|
||||
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
|
||||
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
|
||||
clinica_id TEXT NOT NULL,
|
||||
protetico_id TEXT NOT NULL,
|
||||
favorito BOOLEAN DEFAULT false,
|
||||
padrao BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (clinica_id, protetico_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
+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.30 · commit `6cd8c1f`**.
|
||||
- **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.30 · `6cd8c1f` · 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.30**), 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.
|
||||
|
||||
+1
-1
@@ -318,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-agenda', 'lab-clientes', 'lab-cotacoes', '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.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
|
||||
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
|
||||
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [addEmail, setAddEmail] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const preselected = useRef(false);
|
||||
|
||||
const carregar = useCallback(async (): Promise<any[]> => {
|
||||
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
|
||||
catch { setLabs([]); return []; }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
|
||||
useEffect(() => {
|
||||
if (preselected.current || value) return;
|
||||
const pad = labs.find(l => l.padrao);
|
||||
if (pad) { preselected.current = true; onChange(pad.id, pad); }
|
||||
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const sel = labs.find(l => l.id === value);
|
||||
const q = busca.toLowerCase();
|
||||
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
|
||||
|
||||
const toggleFav = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
|
||||
const adicionar = async () => {
|
||||
if (!addEmail.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
|
||||
if (r?.error) throw new Error(r.error);
|
||||
toast.success('PROTÉTICO VINCULADO.');
|
||||
setAddEmail(''); setShowAdd(false);
|
||||
const novos = await carregar();
|
||||
const novo = novos.find((x: any) => x.id === r.protetico?.id);
|
||||
if (novo) selecionar(novo);
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}
|
||||
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
|
||||
</span>
|
||||
<Search size={16} className="text-gray-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
|
||||
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-4 border-b border-gray-50">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
|
||||
) : filtrados.map(l => (
|
||||
<div key={l.id} onClick={() => selecionar(l)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
|
||||
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
|
||||
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
|
||||
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
|
||||
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
|
||||
{l.nota != null && <span>· ★ {l.nota}</span>}
|
||||
{l.padrao && <span className="text-violet-600">· Padrão</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
|
||||
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
|
||||
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
{showAdd ? (
|
||||
<div className="flex gap-2">
|
||||
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
|
||||
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, X, Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption { value: string; label: string; hint?: string }
|
||||
|
||||
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
|
||||
export const SearchSelect: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busca, setBusca] = useState('');
|
||||
const sel = options.find(o => o.value === value);
|
||||
const q = busca.trim().toLowerCase();
|
||||
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
|
||||
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
|
||||
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
|
||||
<ChevronDown size={16} className="text-gray-400 shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
|
||||
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-3 border-b border-gray-50">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
|
||||
) : filtrados.map(o => (
|
||||
<button key={o.value} onClick={() => pick(o.value)}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
|
||||
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
|
||||
</div>
|
||||
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -748,6 +748,18 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
// Favorito (★) / padrão do protético na clínica ativa.
|
||||
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Vincular um protético existente (com conta) como interno, por e-mail.
|
||||
vincularProteticoEmail: async (email: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const qs = new URLSearchParams({ clinicaId });
|
||||
@@ -1095,6 +1107,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`);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Desambiguação de homônimos na bancada do protético.
|
||||
// O protético identifica o trabalho pelo NOME do paciente (sem CPF — princípio de privacidade).
|
||||
// Quando dois trabalhos têm o mesmo nome+sobrenome, diferenciamos sem expor dado sensível:
|
||||
// 1) pela ORIGEM (dentista, senão clínica) — "João Silva · Dr. Carlos";
|
||||
// 2) se ainda empatar (mesma origem), por nº sequencial estável — "João Silva · Dr. Carlos (2)".
|
||||
// Etiqueta livre, nunca um cadastro/atendimento de paciente.
|
||||
|
||||
const DIACRITICOS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g');
|
||||
const norm = (s?: string) =>
|
||||
(s || '').normalize('NFD').replace(DIACRITICOS, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
const origemDe = (o: any) => (o?.dentista_nome || o?.clinica_nome || o?.cliente_nome || '').trim();
|
||||
|
||||
// Recebe a lista de OS e devolve um Map id → rótulo de exibição já desambiguado.
|
||||
export function rotulosPaciente(lista: any[]): Map<string, string> {
|
||||
const grupos = new Map<string, any[]>();
|
||||
for (const o of lista || []) {
|
||||
const k = norm(o?.paciente_nome);
|
||||
if (!k) continue;
|
||||
const g = grupos.get(k);
|
||||
if (g) g.push(o); else grupos.set(k, [o]);
|
||||
}
|
||||
const out = new Map<string, string>();
|
||||
for (const o of lista || []) {
|
||||
const base = (o?.paciente_nome || '').trim() || 'Sem identificação';
|
||||
const k = norm(o?.paciente_nome);
|
||||
const grupo = k ? (grupos.get(k) || [o]) : [o];
|
||||
if (grupo.length <= 1) { out.set(o.id, base); continue; }
|
||||
const origem = origemDe(o);
|
||||
const mesmaOrigem = grupo.filter(g => norm(origemDe(g)) === norm(origem));
|
||||
if (origem && mesmaOrigem.length === 1) {
|
||||
out.set(o.id, `${base} · ${origem}`);
|
||||
} else {
|
||||
// mesma origem (ou origem ausente) → sequencial estável por data de criação
|
||||
const ord = [...mesmaOrigem].sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
|
||||
const idx = ord.findIndex(g => g.id === o.id) + 1;
|
||||
out.set(o.id, origem ? `${base} · ${origem} (${idx})` : `${base} (${idx})`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Há outro trabalho ATIVO com o mesmo nome+sobrenome? (usado no aviso da entrada do lab)
|
||||
// Retorna a origem do homônimo encontrado, ou null.
|
||||
export function homonimoAtivo(nomeCompleto: string, lista: any[]): string | null {
|
||||
const k = norm(nomeCompleto);
|
||||
if (!k) return null;
|
||||
const hit = (lista || []).find(o =>
|
||||
norm(o?.paciente_nome) === k && !['entregue', 'cancelado'].includes(o?.status));
|
||||
return hit ? (origemDe(hit) || 'outro cliente') : null;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from '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';
|
||||
import { rotulosPaciente, homonimoAtivo } from '../utils/pacienteLabel.ts';
|
||||
|
||||
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 rotuloPac = useMemo(() => rotulosPaciente(os), [os]);
|
||||
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 +66,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 +91,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">
|
||||
@@ -105,7 +150,7 @@ export const LabHomeView: React.FC = () => {
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
|
||||
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{rotuloPac.get(o.id) || o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
|
||||
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
|
||||
@@ -119,6 +164,116 @@ export const LabHomeView: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEntrada && <LabEntradaModal osAtivas={os} onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: dar entrada em OS pelo lado do laboratório ────────────────────────
|
||||
const LabEntradaModal: React.FC<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, 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 [nome, setNome] = useState('');
|
||||
const [sobrenome, setSobrenome] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
|
||||
// Identificação do trabalho = Nome + Sobrenome (etiqueta, sem CPF). Avisa sobre homônimos ativos.
|
||||
const pacienteNome = `${nome} ${sobrenome}`.replace(/\s+/g, ' ').trim();
|
||||
const homonimo = useMemo(() => pacienteNome ? homonimoAtivo(pacienteNome, osAtivas) : null, [pacienteNome, osAtivas]);
|
||||
|
||||
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: pacienteNome || 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>
|
||||
{/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
|
||||
<div>
|
||||
<label className={lbl}>Identificação do trabalho (paciente)</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-1">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} />
|
||||
<input value={sobrenome} onChange={e => setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} />
|
||||
</div>
|
||||
{homonimo && (
|
||||
<p className="mt-1.5 text-[10px] font-bold text-amber-600 uppercase flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="shrink-0 mt-px" /> Já existe “{pacienteNome}” ativo ({homonimo}). Acrescente outro sobrenome para diferenciar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { SearchSelect } from '../components/SearchSelect.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
@@ -123,7 +125,6 @@ export const LancarGTO: React.FC = () => {
|
||||
const [patientSearchTerm, setPatientSearchTerm] = useState('');
|
||||
|
||||
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
|
||||
const [laboratorios, setLaboratorios] = useState<any[]>([]);
|
||||
const [selectedLab, setSelectedLab] = useState<string>('');
|
||||
const [labProdutos, setLabProdutos] = useState<any[]>([]);
|
||||
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
|
||||
@@ -162,12 +163,6 @@ export const LancarGTO: React.FC = () => {
|
||||
[labProdutos, selectedProdutoId]
|
||||
);
|
||||
|
||||
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
|
||||
useEffect(() => {
|
||||
if (!isProtese || laboratorios.length) return;
|
||||
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
}, [isProtese, laboratorios.length]);
|
||||
|
||||
// Catálogo do laboratório selecionado.
|
||||
useEffect(() => {
|
||||
if (!selectedLab) { setLabProdutos([]); return; }
|
||||
@@ -463,10 +458,10 @@ export const LancarGTO: React.FC = () => {
|
||||
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedEsp}
|
||||
onChange={(e) => {
|
||||
setSelectedEsp(e.target.value);
|
||||
onChange={(v) => {
|
||||
setSelectedEsp(v);
|
||||
setSelectedProc(null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
@@ -474,22 +469,13 @@ export const LancarGTO: React.FC = () => {
|
||||
setProtValor('');
|
||||
setProtObs('');
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A ÁREA...</option>
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
|
||||
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
|
||||
/>
|
||||
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedLab}
|
||||
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
@@ -497,18 +483,17 @@ export const LancarGTO: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedProdutoId}
|
||||
onChange={(e) => {
|
||||
setSelectedProdutoId(e.target.value);
|
||||
const pr = labProdutos.find(p => p.id === e.target.value);
|
||||
onChange={(v) => {
|
||||
setSelectedProdutoId(v);
|
||||
const pr = labProdutos.find(p => p.id === v);
|
||||
setProtValor(pr ? String(pr.preco ?? '') : '');
|
||||
}}
|
||||
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
|
||||
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A PRÓTESE...</option>
|
||||
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} — R$ {Number(p.preco || 0).toFixed(2)}</option>)}
|
||||
</select>
|
||||
/>
|
||||
|
||||
{selectedProduto && (
|
||||
<>
|
||||
@@ -556,21 +541,20 @@ export const LancarGTO: React.FC = () => {
|
||||
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
onChange={(v) => {
|
||||
const pr = procedimentos.find(p => p.id === v);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setArcoFace('');
|
||||
setArcoObs('');
|
||||
}}
|
||||
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
|
||||
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProc && (
|
||||
@@ -601,28 +585,23 @@ export const LancarGTO: React.FC = () => {
|
||||
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
|
||||
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
|
||||
</div>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={store.dentista?.id || ''}
|
||||
onChange={(e) => {
|
||||
const d = dentistas.find(d => d.id === e.target.value);
|
||||
store.setDentista(d || null);
|
||||
}}
|
||||
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
|
||||
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
|
||||
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O PROFISSIONAL</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
|
||||
<div className="pt-1">
|
||||
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={store.credenciada || ''}
|
||||
onChange={(e) => store.setCredenciada(e.target.value)}
|
||||
onChange={(v) => store.setCredenciada(v)}
|
||||
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
|
||||
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O CONVÊNIO</option>
|
||||
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } 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, FileText, Star } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { SearchSelect } from '../components/SearchSelect.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('/');
|
||||
@@ -62,7 +65,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({
|
||||
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
|
||||
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [pickerKey, setPickerKey] = useState(0);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
@@ -73,7 +76,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [ws?.id]);
|
||||
|
||||
@@ -151,20 +153,17 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
{/* Laboratório */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
|
||||
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<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>
|
||||
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
|
||||
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
|
||||
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
|
||||
</div>
|
||||
{/* Produto (catálogo do laboratório — preço fixo) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
|
||||
{form.protetico_id && produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => set('produto_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(p.preco)}</option>)}
|
||||
</select>
|
||||
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
|
||||
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
|
||||
placeholder="Selecione..." title="Produto do laboratório"
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
|
||||
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
|
||||
@@ -176,10 +175,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
{/* Dentista */}
|
||||
<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 className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
|
||||
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
|
||||
placeholder="—" title="Dentista"
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
</div>
|
||||
{/* Material + cor + dentes */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -875,6 +874,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>
|
||||
@@ -909,6 +915,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>
|
||||
);
|
||||
};
|
||||
@@ -997,6 +1009,7 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
|
||||
const [dentes, setDentes] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [buscaLab, setBuscaLab] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
|
||||
@@ -1039,8 +1052,12 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||
{labs.map(l => (
|
||||
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).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>
|
||||
@@ -1089,6 +1106,8 @@ export const ProteseView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
// Rótulos desambiguados de homônimos (só faz sentido na bancada, onde o protético lê o nome sem CPF).
|
||||
const rotuloPac = useMemo(() => modo === 'bancada' ? rotulosPaciente(lista) : new Map<string, string>(), [lista, modo]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
@@ -1162,7 +1181,7 @@ export const ProteseView: React.FC = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{modo === 'bancada' ? (rotuloPac.get(os.id) || os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
|
||||
</p>
|
||||
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
|
||||
|
||||
Reference in New Issue
Block a user