feat(protese): módulo de OS de prótese (clínica ↔ laboratório/protético)
- backend: tabelas protese_os/_evento/_foto/_produtos + endpoints /api/protese/* (laboratorios, os, bancada, status, foto/raw assinado, produtos); catálogo do protético; custo de lab → DESPESA na entrega (idempotente, estorno no delete) + garantia com foto. - Acesso: helper proteticoAcessivelPelaClinica (interno por vínculo OU diretório) valida o POST e o catálogo; catálogos passam por tenantGuard; correção do label da notificação (tipo resolvido). - Frontend: ProteseView (Bancada do protético / Minhas OS da clínica) + rota /proteses + item de menu PRÓTESES no Sidebar (dentista/protético/funcionário). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -452,6 +452,36 @@ async function seedBiomedicina(clinicaId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral).
|
||||
// Garantia padrão de 12 meses por produto.
|
||||
const PROTESE_PRODUTOS_PADRAO = [
|
||||
{ nome: 'Coroa de Zircônia', preco: 350 },
|
||||
{ nome: 'Coroa Metalocerâmica', preco: 250 },
|
||||
{ nome: 'Coroa Provisória', preco: 80 },
|
||||
{ nome: 'Faceta / Laminado de Porcelana', preco: 400 },
|
||||
{ nome: 'Onlay / Inlay Cerâmico', preco: 300 },
|
||||
{ nome: 'Núcleo Metálico Fundido', preco: 90 },
|
||||
{ nome: 'Prótese Total (Dentadura)', preco: 600 },
|
||||
{ nome: 'Prótese Parcial Removível (PPR)', preco: 500 },
|
||||
{ nome: 'Coroa sobre Implante', preco: 600 },
|
||||
{ nome: 'Protocolo sobre Implante (arco)', preco: 1800 },
|
||||
{ nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 },
|
||||
{ nome: 'Provisório em Resina (unidade)', preco: 60 },
|
||||
];
|
||||
// Idempotente: só popula se o protético ainda não tem nenhum produto.
|
||||
async function seedProteseProdutos(proteticoId) {
|
||||
if (!proteticoId) return false;
|
||||
const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]);
|
||||
if (rows.length) return false;
|
||||
for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) {
|
||||
const p = PROTESE_PRODUTOS_PADRAO[i];
|
||||
await pool.query(
|
||||
"INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)",
|
||||
[`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- REGISTRO DE USUÁRIOS ---
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
|
||||
@@ -492,6 +522,8 @@ app.post('/api/register', async (req, res) => {
|
||||
workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }];
|
||||
// Biomédico já nasce com o catálogo de biomedicina estética no consultório dele.
|
||||
if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } }
|
||||
// Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia).
|
||||
if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } }
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
|
||||
@@ -3616,6 +3648,290 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) =>
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ═══ PRÓTESES · Ordem de Serviço (OS): clínica/dentista → laboratório/protético ═══
|
||||
// Cross-workspace (padrão das propostas): a clínica cria por tenantGuard (clinica_id origem);
|
||||
// o protético vê na "Bancada" por authGuard (protetico_id = destinatário).
|
||||
const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado'];
|
||||
|
||||
// Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo).
|
||||
async function proteseOsAccesso(os, uid) {
|
||||
if (!os) return { isLab: false, isClinic: false };
|
||||
const isLab = !!uid && os.protetico_id === uid;
|
||||
let isClinic = false;
|
||||
if (uid) {
|
||||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]);
|
||||
isClinic = rows.length > 0;
|
||||
}
|
||||
return { isLab, isClinic };
|
||||
}
|
||||
|
||||
// Um protético é "acessível" por uma clínica se for interno (vínculo) OU estiver no diretório.
|
||||
// Mesma regra do dropdown de Nova OS — usada para travar criação de OS e leitura de catálogo.
|
||||
async function proteticoAcessivelPelaClinica(proteticoId, clinicaId) {
|
||||
if (!proteticoId || !clinicaId) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $2
|
||||
WHERE u.id = $1 AND u.role = 'protetico'
|
||||
AND (v.clinica_id = $2 OR u.disponivel_diretorio = true)
|
||||
LIMIT 1`, [proteticoId, clinicaId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// Laboratórios/protéticos disponíveis para o dropdown da Nova OS:
|
||||
// protéticos com vínculo na clínica (internos) + protéticos no diretório (externos).
|
||||
app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
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
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
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]);
|
||||
res.json(rows.map(r => ({ ...r, interno: !!r.interno })));
|
||||
} 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) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
await seedProteseProdutos(uid); // idempotente — protéticos antigos ganham o catálogo padrão
|
||||
const { rows } = await pool.query('SELECT * FROM protese_produtos WHERE protetico_id=$1 AND deleted_at IS NULL ORDER BY ordem, nome', [uid]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Catálogo ATIVO de um laboratório (lido pela clínica ao criar a OS).
|
||||
app.get('/api/protese/laboratorios/:id/produtos', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
if (!await proteticoAcessivelPelaClinica(req.params.id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
await seedProteseProdutos(req.params.id);
|
||||
const { rows } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE protetico_id=$1 AND ativo=true AND deleted_at IS NULL ORDER BY ordem, nome', [req.params.id]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.post('/api/protese/produtos', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.nome) return res.status(400).json({ error: 'nome obrigatório.' });
|
||||
try {
|
||||
const id = `ppr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[id, req.authUser.userId, b.nome, Number(b.preco) || 0, Number(b.garantia_meses) || 12, b.ativo !== false, Number(b.ordem) || 0]);
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE protese_produtos SET nome=COALESCE($1,nome), preco=COALESCE($2,preco), garantia_meses=COALESCE($3,garantia_meses), ativo=COALESCE($4,ativo) WHERE id=$5 AND protetico_id=$6 AND deleted_at IS NULL',
|
||||
[b.nome ?? null, b.preco != null ? Number(b.preco) : null, b.garantia_meses != null ? Number(b.garantia_meses) : null, b.ativo ?? null, req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.delete('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('UPDATE protese_produtos SET deleted_at=NOW() WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Criar OS (clínica origem).
|
||||
app.post('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const b = req.body || {};
|
||||
if (!b.protetico_id) return res.status(400).json({ error: 'protetico_id (laboratório) obrigatório.' });
|
||||
try {
|
||||
const { rows: prot } = await pool.query("SELECT id, nome FROM usuarios WHERE id=$1 AND role='protetico'", [b.protetico_id]);
|
||||
if (!prot.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
// Só pode pedir OS a protético interno (vínculo) ou no diretório — mesma regra do dropdown.
|
||||
if (!await proteticoAcessivelPelaClinica(b.protetico_id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
// Produto do catálogo do laboratório = preço fixo (custo de lab) + garantia.
|
||||
let tipo = b.tipo || 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, b.protetico_id]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este 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: 'Selecione um produto/tipo de prótese.' });
|
||||
// OS de garantia: refaz uma OS anterior sem novo custo (dentro da garantia).
|
||||
const ehGarantia = b.tipo_os === 'garantia' && !!b.origem_os_id;
|
||||
if (ehGarantia) custo = 0;
|
||||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const uid = req.authUser?.userId;
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`,
|
||||
[id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null,
|
||||
b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null,
|
||||
tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses,
|
||||
b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
|
||||
b.prazo_entrega || null, Number(b.valor) || 0, custo, 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)}`, id, req.clinicaId, 'OS criada', uid, await actorNome(uid)]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: id, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { protetico_id: b.protetico_id, tipo } });
|
||||
await notificarUsuario(b.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${tipo}). Veja na Bancada.`);
|
||||
wsBroadcast(req.clinicaId, 'protese', { entidadeId: id, acao: 'criou' });
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Lista da clínica origem (dentista/recepção).
|
||||
app.get('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const vals = [req.clinicaId];
|
||||
let where = 'clinica_id=$1 AND deleted_at IS NULL';
|
||||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||||
if (req.query.paciente_id) { vals.push(req.query.paciente_id); where += ` AND paciente_id=$${vals.length}`; }
|
||||
const { rows } = await pool.query(`SELECT * FROM protese_os WHERE ${where} ORDER BY created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Bancada do laboratório (protético): OS destinadas a mim, com flag de atraso.
|
||||
app.get('/api/protese/bancada', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const vals = [uid];
|
||||
let where = 'protetico_id=$1 AND deleted_at IS NULL';
|
||||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
|
||||
FROM protese_os WHERE ${where}
|
||||
ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Detalhe + histórico de eventos + fotos.
|
||||
app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const { rows: eventos } = await pool.query('SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: fotos } = await pool.query('SELECT id, original_name FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
res.json({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
eventos,
|
||||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
const novo = req.body?.status;
|
||||
if (!PROTESE_STATUS.includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const uid = req.authUser.userId;
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
if (os.status === 'cancelado' || os.status === 'entregue') return res.status(409).json({ error: `OS já está "${os.status}".` });
|
||||
// Entregue/cancelado são da clínica; estados de produção são do laboratório.
|
||||
const estadosClinica = ['entregue', 'cancelado'];
|
||||
if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' });
|
||||
if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' });
|
||||
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia).
|
||||
let garantiaAte = null, finId = os.financeiro_id || null;
|
||||
if (novo === 'entregue') {
|
||||
const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]);
|
||||
if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' });
|
||||
const meses = Number(os.garantia_meses) || 12;
|
||||
const { rows: gd } = await pool.query(`SELECT (CURRENT_DATE + ($1 || ' months')::interval)::date AS d`, [meses]);
|
||||
garantiaAte = gd[0].d;
|
||||
// Custo de laboratório → DESPESA (contas a pagar ao laboratório), idempotente. OS de garantia não cobra.
|
||||
if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
|
||||
finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao)
|
||||
VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`,
|
||||
[finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo),
|
||||
os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]);
|
||||
}
|
||||
}
|
||||
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id) WHERE id=$4', [novo, garantiaAte, finId, os.id]);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]);
|
||||
await registrarAudit(pool, { clinicaId: os.clinica_id, entidade: 'protese_os', entidadeId: os.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { financeiro_id: finId, garantia_ate: garantiaAte } });
|
||||
// Notifica a outra parte.
|
||||
const destino = isLab ? os.created_by : os.protetico_id;
|
||||
if (destino) await notificarUsuario(destino, `Prótese: ${novo}`, `A OS de ${os.paciente_nome || 'paciente'} (${os.tipo}) mudou para "${novo}".`, novo === 'entregue' ? 'sucesso' : 'info');
|
||||
wsBroadcast(os.clinica_id, 'protese', { entidadeId: os.id, acao: novo });
|
||||
res.json({ success: true, status: novo, financeiro_id: finId, garantia_ate: garantiaAte });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Anexar foto do trabalho (laboratório ou clínica).
|
||||
app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
const os = rows[0];
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||||
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
|
||||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||||
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]);
|
||||
res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Stream da imagem (URL assinada — img tag não envia Authorization).
|
||||
app.get('/api/protese/os/fotos/:id/raw', async (req, res) => {
|
||||
try {
|
||||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||||
const { rows } = await pool.query('SELECT filename, mimetype FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).end();
|
||||
res.type(rows[0].mimetype || 'image/jpeg');
|
||||
res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); });
|
||||
} catch { res.status(404).end(); }
|
||||
});
|
||||
|
||||
// Excluir OS (só a clínica de origem; soft delete).
|
||||
app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT financeiro_id FROM protese_os WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
await pool.query('UPDATE protese_os SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]);
|
||||
// Estorna a DESPESA de laboratório vinculada (se houver).
|
||||
if (rows[0].financeiro_id) await pool.query('UPDATE financeiro SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: req.params.id, acao: 'excluiu', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { estornou: rows[0].financeiro_id || null } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// --- CONTRATOS ---
|
||||
app.get('/api/orcamentos', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -5142,6 +5458,75 @@ async function runMigrations() {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`,
|
||||
// ── PRÓTESES: Ordem de Serviço (clínica → laboratório/protético) ─────
|
||||
`CREATE TABLE IF NOT EXISTS protese_os (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT NOT NULL, -- clínica origem (quem pediu) — escopo tenantGuard
|
||||
laboratorio_id TEXT, -- workspace destino (futuro; null = roteia por protetico_id)
|
||||
protetico_id TEXT, -- usuário destinatário (a "bancada")
|
||||
protetico_nome TEXT,
|
||||
paciente_id TEXT,
|
||||
paciente_nome TEXT,
|
||||
dentista_id TEXT,
|
||||
dentista_nome TEXT,
|
||||
procedimento_id TEXT,
|
||||
agendamento_id TEXT,
|
||||
tipo TEXT, -- fixa | removível | total | sobre implante | provisório...
|
||||
material TEXT,
|
||||
cor TEXT, -- escala de cor
|
||||
dentes TEXT, -- FDI livre (ex: 11,21)
|
||||
observacoes TEXT,
|
||||
prazo_entrega DATE,
|
||||
valor NUMERIC DEFAULT 0, -- preço cobrado pela OS
|
||||
custo NUMERIC DEFAULT 0, -- custo de laboratório (elo futuro com comissão)
|
||||
status TEXT DEFAULT 'solicitado',-- solicitado|recebido|producao|prova|ajuste|finalizado|entregue|cancelado
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_clinica ON protese_os (clinica_id, status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_lab ON protese_os (protetico_id, status)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_evento (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
status TEXT,
|
||||
observacao TEXT,
|
||||
actor_id TEXT,
|
||||
actor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_evento ON protese_os_evento (os_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_foto (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
filename TEXT,
|
||||
mimetype TEXT,
|
||||
original_name TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_foto ON protese_os_foto (os_id)`,
|
||||
// Catálogo de produtos do protético (preço fixo, sem especialidade — geral de prótese) + garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS produto_id TEXT`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS tipo_os TEXT DEFAULT 'nova'`, // nova | garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS origem_os_id TEXT`, // OS original (em garantia)
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
nome TEXT NOT NULL,
|
||||
preco NUMERIC DEFAULT 0, -- preço fixo cobrado da clínica (= custo de laboratório)
|
||||
garantia_meses INTEGER DEFAULT 12,
|
||||
ativo BOOLEAN DEFAULT true,
|
||||
ordem INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
|
||||
|
||||
+8
-3
@@ -47,6 +47,7 @@ import { AdminGestaoTutoresView } from './views/AdminGestaoTutoresView.tsx';
|
||||
import { WaitingInviteView } from './views/WaitingInviteView.tsx';
|
||||
import { SuperAdminView } from './views/SuperAdminView.tsx';
|
||||
import { DiretorioProfissionaisView } from './views/DiretorioProfissionaisView.tsx';
|
||||
import { ProteseView } from './views/ProteseView.tsx';
|
||||
|
||||
export type ViewKey =
|
||||
| 'landing'
|
||||
@@ -59,6 +60,7 @@ export type ViewKey =
|
||||
| 'financeiro'
|
||||
| 'comissoes'
|
||||
| 'glosas'
|
||||
| 'proteses'
|
||||
| 'login'
|
||||
| 'update'
|
||||
| 'dentistas'
|
||||
@@ -147,6 +149,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/superadmin/notificacoes': 'superadmin-notificacoes',
|
||||
'/aguardando-convite': 'aguardando-convite',
|
||||
'/diretorio-profissionais': 'diretorio-profissionais',
|
||||
'/proteses': 'proteses',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
@@ -198,6 +201,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'superadmin-financeiro': '/superadmin/financeiro',
|
||||
'superadmin-notificacoes': '/superadmin/notificacoes',
|
||||
'diretorio-profissionais': '/diretorio-profissionais',
|
||||
proteses: '/proteses',
|
||||
};
|
||||
|
||||
/** Resolve current URL to a ViewKey */
|
||||
@@ -293,10 +297,10 @@ const App: React.FC = () => {
|
||||
|
||||
const permissions: Record<string, ViewKey[]> = {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', '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'],
|
||||
};
|
||||
|
||||
return permissions[role]?.includes(view) || false;
|
||||
@@ -455,6 +459,7 @@ const App: React.FC = () => {
|
||||
case 'relatorios': return <ReportsView />;
|
||||
case 'comissoes': return <ComissoesView />;
|
||||
case 'glosas': return <GlosasView />;
|
||||
case 'proteses': return <ProteseView />;
|
||||
case 'cadastro-dentista': return <DentistRegisterView />;
|
||||
case 'configuracoes': return <ConfiguracoesView />;
|
||||
case 'orcamentos': return <OrcamentosView />;
|
||||
|
||||
@@ -62,6 +62,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
|
||||
{ id: 'proteses', label: 'PRÓTESES', icon: FlaskConical },
|
||||
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
|
||||
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
|
||||
@@ -122,13 +123,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
if (currentRole === 'donosala') return ['notificacoes', 'configuracoes'].includes(item.id);
|
||||
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
// Dentista: clínico odonto completo (ortodontia, tutoria orto).
|
||||
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
|
||||
// Biomédico(a): seu próprio catálogo (especialidades/procedimentos de biomedicina);
|
||||
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
|
||||
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
|
||||
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -742,6 +742,79 @@ export const HybridBackend = {
|
||||
await apiFetch(`${API_URL}/glosas/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// ── Próteses · Ordem de Serviço (clínica ↔ laboratório/protético) ───────────
|
||||
getProteseLaboratorios: async () => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const qs = new URLSearchParams({ clinicaId });
|
||||
if (opts.status) qs.set('status', opts.status);
|
||||
if (opts.paciente_id) qs.set('paciente_id', opts.paciente_id);
|
||||
const res = await apiFetch(`${API_URL}/protese/os?${qs.toString()}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseBancada: async (status?: string) => {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
const res = await apiFetch(`${API_URL}/protese/bancada${qs}`);
|
||||
return await res.json();
|
||||
},
|
||||
getProteseOSDetalhe: async (id: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}`);
|
||||
return await res.json();
|
||||
},
|
||||
createProteseOS: async (payload: any) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/os?clinicaId=${encodeURIComponent(clinicaId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar OS');
|
||||
return await res.json();
|
||||
},
|
||||
setProteseOSStatus: async (id: string, status: string, observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
uploadProteseFoto: async (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseOS: async (id: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
},
|
||||
// Catálogo do protético (preço fixo + garantia)
|
||||
getProteseProdutos: async () => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos`);
|
||||
return await res.json();
|
||||
},
|
||||
getLaboratorioProdutos: async (proteticoId: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/produtos?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
createProteseProduto: async (payload: { nome: string; preco?: number; garantia_meses?: number }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar produto');
|
||||
return await res.json();
|
||||
},
|
||||
updateProteseProduto: async (id: string, payload: any) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/produtos/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar produto');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseProduto: async (id: string) => {
|
||||
await apiFetch(`${API_URL}/protese/produtos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// ── GTO (builder → finaliza → lança no financeiro) ──────────────────────────
|
||||
createGtoBuilder: async (payload: { pacienteId: string; dentistaId: string; credenciadaId: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/gto-builder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check } 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';
|
||||
|
||||
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 STATUS_LABEL: Record<string, string> = {
|
||||
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
|
||||
prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado',
|
||||
};
|
||||
const STATUS_COR: Record<string, string> = {
|
||||
solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700',
|
||||
prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700',
|
||||
entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600',
|
||||
};
|
||||
const LAB_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
// ── 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 [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
const [paciente, setPaciente] = useState<any>(null);
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [form, setForm] = useState<any>({ protetico_id: '', produto_id: '', tipo: '', dentista_id: '', material: '', cor: '', dentes: '', prazo_entrega: '', valor: '', observacoes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
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]);
|
||||
|
||||
// Catálogo (preço fixo) do laboratório escolhido.
|
||||
useEffect(() => {
|
||||
if (!form.protetico_id) { setProdutos([]); return; }
|
||||
HybridBackend.getLaboratorioProdutos(form.protetico_id)
|
||||
.then(r => setProdutos(Array.isArray(r) ? r : []))
|
||||
.catch(() => setProdutos([]));
|
||||
setForm((f: any) => ({ ...f, produto_id: '', tipo: '' }));
|
||||
}, [form.protetico_id]);
|
||||
|
||||
const produtoSel = produtos.find(p => p.id === form.produto_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.protetico_id) { toast.error('SELECIONE O LABORATÓRIO.'); return; }
|
||||
if (!form.produto_id && !form.tipo) { toast.error('SELECIONE O PRODUTO.'); return; }
|
||||
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||
setSaving(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
...form,
|
||||
valor: Number(form.valor) || 0,
|
||||
paciente_id: paciente?.id || null,
|
||||
paciente_nome: paciente?.nome || null,
|
||||
dentista_nome: dent?.nome || null,
|
||||
});
|
||||
toast.success('OS DE PRÓTESE CRIADA.');
|
||||
onSaved();
|
||||
} catch (e: any) { toast.error((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"><FlaskConical size={16} className="text-violet-600" /> Nova OS de Prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Paciente */}
|
||||
<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>
|
||||
{/* 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)' : ''}</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>}
|
||||
</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>
|
||||
) : (
|
||||
<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" />
|
||||
)}
|
||||
{produtoSel && (
|
||||
<p className="text-[10px] text-gray-500 mt-1">Custo de laboratório <b>{fmtBRL(produtoSel.preco)}</b> · garantia <b>{produtoSel.garantia_meses} meses</b></p>
|
||||
)}
|
||||
</div>
|
||||
{/* 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>
|
||||
{/* Material + cor + dentes */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Material</label><input value={form.material} onChange={e => set('material', e.target.value)} placeholder="Zircônia..." 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">Cor</label><input value={form.cor} onChange={e => set('cor', e.target.value)} placeholder="A2..." 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">Dentes</label><input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="11,21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
</div>
|
||||
{/* Prazo + valor cobrado do paciente */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="text-[10px] font-black text-gray-500 uppercase">Prazo de entrega</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><label className="text-[10px] font-black text-gray-500 uppercase">Valor ao paciente R$</label><input type="number" value={form.valor} onChange={e => set('valor', e.target.value)} placeholder="0,00" 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">Observações</label>
|
||||
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 border-t border-gray-100 flex gap-3 sticky bottom-0 bg-white">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-gray-200 font-black text-gray-500 text-sm uppercase">Cancelar</button>
|
||||
<button onClick={salvar} disabled={saving} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-sm uppercase disabled:opacity-50">{saving ? 'Salvando...' : 'Criar OS'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: Detalhe da OS (timeline + fotos + ações por papel) ───────────────
|
||||
const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose: () => void; onChanged: () => void }> = ({ id, modo, onClose, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
||||
}, [id, toast]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const mudar = async (status: string) => {
|
||||
if (status === 'cancelado' && !(await confirm('CANCELAR ESTA OS?'))) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.setProteseOSStatus(id, status); toast.success(`OS: ${(STATUS_LABEL[status] || status).toUpperCase()}.`); carregar(); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(id, file); toast.success('FOTO ANEXADA.'); carregar(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acionarGarantia = async () => {
|
||||
if (!(await confirm('ABRIR UMA OS DE GARANTIA (SEM CUSTO) PARA REFAZER ESTE TRABALHO?'))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
protetico_id: os.protetico_id, produto_id: os.produto_id || null, tipo: os.tipo,
|
||||
tipo_os: 'garantia', origem_os_id: os.id,
|
||||
paciente_id: os.paciente_id, paciente_nome: os.paciente_nome,
|
||||
dentista_id: os.dentista_id, dentista_nome: os.dentista_nome,
|
||||
material: os.material, cor: os.cor, dentes: os.dentes,
|
||||
observacoes: `Garantia da OS ${os.id}`,
|
||||
});
|
||||
toast.success('OS DE GARANTIA ABERTA (SEM CUSTO).');
|
||||
onChanged(); onClose();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!os) return null;
|
||||
const finalizado = os.status === 'entregue' || os.status === 'cancelado';
|
||||
const naGarantia = os.status === 'entregue' && os.garantia_ate && os.garantia_ate >= new Date().toISOString().slice(0, 10);
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge s={os.status} />
|
||||
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
|
||||
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> {dataBR(os.prazo_entrega)}</span>}
|
||||
</div>
|
||||
{os.garantia_ate && (
|
||||
<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold">
|
||||
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
|
||||
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
|
||||
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor</span>{fmtBRL(os.valor)}</div>}
|
||||
</div>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
|
||||
|
||||
{/* Fotos */}
|
||||
{os.fotos?.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{os.fotos.map((f: any) => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-100" /></a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-400 mt-1 shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-gray-700">{STATUS_LABEL[ev.status] || ev.observacao || ev.status}</span>
|
||||
<span className="text-gray-400"> · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações por papel */}
|
||||
{!finalizado && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
|
||||
{modo === 'bancada' ? (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LAB_FLUXO.map(s => (
|
||||
<button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)}
|
||||
className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer">
|
||||
<ImagePlus size={15} /> Anexar foto do trabalho
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto antes de entregar (garantia)</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
|
||||
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ───────
|
||||
const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [itens, setItens] = useState<any[]>([]);
|
||||
const [novo, setNovo] = useState({ nome: '', preco: '', garantia_meses: '12' });
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState<any>({});
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseProdutos().then(r => setItens(Array.isArray(r) ? r : [])).catch(() => setItens([]));
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const add = async () => {
|
||||
if (!novo.nome.trim()) { toast.error('INFORME O NOME.'); return; }
|
||||
try { await HybridBackend.createProteseProduto({ nome: novo.nome.trim(), preco: Number(novo.preco) || 0, garantia_meses: Number(novo.garantia_meses) || 12 });
|
||||
setNovo({ nome: '', preco: '', garantia_meses: '12' }); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
const salvarEdit = async (id: string) => {
|
||||
try { await HybridBackend.updateProteseProduto(id, { nome: edit.nome, preco: Number(edit.preco) || 0, garantia_meses: Number(edit.garantia_meses) || 12 }); setEditId(null); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
const remover = async (id: string) => {
|
||||
if (!(await confirm('REMOVER ESTE PRODUTO DO CATÁLOGO?'))) return;
|
||||
try { await HybridBackend.deleteProteseProduto(id); carregar(); } catch { toast.error('ERRO AO REMOVER.'); }
|
||||
};
|
||||
|
||||
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"><Tags size={16} className="text-violet-600" /> Meus produtos de prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-2">
|
||||
{/* Adicionar */}
|
||||
<div className="flex gap-2 items-end bg-gray-50 rounded-xl p-3">
|
||||
<div className="flex-1"><label className="text-[9px] font-black text-gray-500 uppercase">Produto</label><input value={novo.nome} onChange={e => setNovo({ ...novo, nome: e.target.value })} placeholder="Coroa de Zircônia" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<div className="w-20"><label className="text-[9px] font-black text-gray-500 uppercase">Preço</label><input type="number" value={novo.preco} onChange={e => setNovo({ ...novo, preco: e.target.value })} placeholder="0" className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<div className="w-16"><label className="text-[9px] font-black text-gray-500 uppercase">Gar.(m)</label><input type="number" value={novo.garantia_meses} onChange={e => setNovo({ ...novo, garantia_meses: e.target.value })} className="w-full mt-1 px-2 py-1.5 rounded-lg border border-gray-200 text-sm" /></div>
|
||||
<button onClick={add} className="p-2 rounded-lg bg-violet-600 text-white"><Plus size={16} /></button>
|
||||
</div>
|
||||
{/* Lista */}
|
||||
{itens.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-100">
|
||||
{editId === p.id ? (
|
||||
<>
|
||||
<input value={edit.nome} onChange={e => setEdit({ ...edit, nome: e.target.value })} className="flex-1 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<input type="number" value={edit.preco} onChange={e => setEdit({ ...edit, preco: e.target.value })} className="w-20 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<input type="number" value={edit.garantia_meses} onChange={e => setEdit({ ...edit, garantia_meses: e.target.value })} className="w-14 px-2 py-1 rounded-lg border border-gray-200 text-sm" />
|
||||
<button onClick={() => salvarEdit(p.id)} className="text-green-600"><Check size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm font-bold text-gray-700">{p.nome}</span>
|
||||
<span className="text-sm text-gray-500">{fmtBRL(p.preco)}</span>
|
||||
<span className="text-[10px] text-gray-400">{p.garantia_meses}m</span>
|
||||
<button onClick={() => { setEditId(p.id); setEdit({ nome: p.nome, preco: p.preco, garantia_meses: p.garantia_meses }); }} className="text-gray-300 hover:text-violet-600"><Pencil size={14} /></button>
|
||||
<button onClick={() => remover(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={14} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{itens.length === 0 && <p className="text-center text-gray-400 text-sm py-6">Nenhum produto. Adicione acima.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── View principal ──────────────────────────────────────────────────────────
|
||||
export const ProteseView: React.FC = () => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
const [detalhe, setDetalhe] = useState<string | null>(null);
|
||||
const [produtosModal, setProdutosModal] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
setLoading(true);
|
||||
const p = modo === 'bancada'
|
||||
? HybridBackend.getProteseBancada(filtro || undefined)
|
||||
: HybridBackend.getProteseOS(filtro ? { status: filtro } : {});
|
||||
p.then(r => setLista(Array.isArray(r) ? r : [])).catch(() => setLista([])).finally(() => setLoading(false));
|
||||
}, [modo, filtro]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const excluir = async (id: string) => {
|
||||
if (!(await confirm('EXCLUIR ESTA OS?'))) return;
|
||||
try { await HybridBackend.deleteProteseOS(id); toast.success('OS EXCLUÍDA.'); carregar(); }
|
||||
catch { toast.error('ERRO AO EXCLUIR.'); }
|
||||
};
|
||||
|
||||
const STATUS_FILTRO = modo === 'bancada'
|
||||
? ['', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue']
|
||||
: ['', 'solicitado', 'recebido', 'producao', 'finalizado', 'entregue', 'cancelado'];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
title={modo === 'bancada' ? 'Bancada de Próteses' : 'Próteses'}
|
||||
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={() => 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>
|
||||
<button onClick={carregar} className="flex items-center gap-2 border border-gray-200 text-gray-500 px-3 py-2 rounded-xl font-black text-xs uppercase"><RefreshCw size={14} /> Atualizar</button>
|
||||
</>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Filtros de status */}
|
||||
<div className="flex gap-2 flex-wrap mb-4 mt-2">
|
||||
{STATUS_FILTRO.map(s => (
|
||||
<button key={s} onClick={() => setFiltro(s)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase ${filtro === s ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{s === '' ? 'Todas' : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-400 py-12 text-sm">Carregando...</p>
|
||||
) : lista.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<FlaskConical size={40} className="mx-auto text-gray-200 mb-3" />
|
||||
<p className="text-gray-400 text-sm font-bold uppercase">{modo === 'bancada' ? 'Nenhum trabalho na bancada' : 'Nenhuma OS de prótese'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lista.map(os => (
|
||||
<div key={os.id} onClick={() => setDetalhe(os.id)}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-4 flex items-center gap-3 hover:border-violet-200 cursor-pointer transition-colors">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${os.atrasada ? 'bg-red-100' : 'bg-violet-100'}`}>
|
||||
<FlaskConical size={18} className={os.atrasada ? 'text-red-500' : 'text-violet-600'} />
|
||||
</div>
|
||||
<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')}
|
||||
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}
|
||||
<Badge s={os.status} />
|
||||
{modo === 'clinica' && os.status !== 'entregue' && (
|
||||
<button onClick={e => { e.stopPropagation(); excluir(os.id); }} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
)}
|
||||
<ChevronRight size={16} className="text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProteseView;
|
||||
Reference in New Issue
Block a user