Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84f4a797d2 | |||
| ae48feaa8a | |||
| c8d679eb70 | |||
| 3ffaff9d7d | |||
| 7ae6993c4b | |||
| 9f103be397 | |||
| f4f433b14c | |||
| 8cf4a83161 | |||
| 623c9990d6 | |||
| b80d061a6f |
+556
-12
@@ -3793,9 +3793,24 @@ async function labDoProtetico(proteticoId) {
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
// Fase 4: o laboratório do usuário — como DONO (owner) ou como TÉCNICO (vínculo). Usado na bancada.
|
||||
async function labDoUsuario(uid) {
|
||||
if (!uid) return null;
|
||||
const dono = await labDoProtetico(uid);
|
||||
if (dono) return dono;
|
||||
const { rows } = await pool.query(
|
||||
"SELECT c.id FROM vinculos v JOIN clinicas c ON c.id = v.clinica_id WHERE v.usuario_id=$1 AND c.tipo='laboratorio' AND v.role='tecnico_protese' LIMIT 1", [uid]);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function proteseOsAccesso(os, uid) {
|
||||
if (!os) return { isLab: false, isClinic: false };
|
||||
const isLab = !!uid && os.protetico_id === uid;
|
||||
let isLab = !!uid && os.protetico_id === uid;
|
||||
// Técnico vinculado ao laboratório de destino também é "lab" (acessa a produção).
|
||||
if (!isLab && uid && os.laboratorio_id) {
|
||||
const { rows } = await pool.query("SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [uid, os.laboratorio_id]);
|
||||
isLab = rows.length > 0;
|
||||
}
|
||||
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]);
|
||||
@@ -3832,7 +3847,31 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
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 })));
|
||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||
const ids = rows.map(r => r.id);
|
||||
const notas = {};
|
||||
if (ids.length) {
|
||||
const { rows: ag } = await pool.query(
|
||||
`SELECT protetico_id,
|
||||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
||||
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
|
||||
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados
|
||||
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
|
||||
const { rows: ocs } = await pool.query(
|
||||
`SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]);
|
||||
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
|
||||
for (const g of ag) {
|
||||
if (g.entregues >= SCORE_MIN_OS) {
|
||||
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
|
||||
const nota = 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3);
|
||||
notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id] ?? null }));
|
||||
// Ranking: internos primeiro, depois maior nota, depois nome.
|
||||
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
res.json(lista);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
@@ -3951,37 +3990,452 @@ app.get('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
app.get('/api/protese/bancada', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoProtetico(uid); // Fase 2: a bancada é do LABORATÓRIO (empresa)
|
||||
const labId = await labDoUsuario(uid); // Fase 2/4: bancada do LABORATÓRIO (dono ou técnico)
|
||||
const vals = [uid, labId];
|
||||
// Roteia por laboratorio_id; fallback por pessoa cobre OS legadas sem laboratorio_id.
|
||||
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2) OR (laboratorio_id IS NULL AND 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
|
||||
`SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome,
|
||||
(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 })));
|
||||
// Bancada é a fila do laboratório: vê a clínica de origem e o próprio custo, mas NÃO o
|
||||
// valor cobrado ao paciente nem o CPF (paciente_id é o CPF).
|
||||
res.json(rows.map(r => ({ ...r, valor: 0, paciente_id: null, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 3 — Clientes do laboratório: clínicas que enviam OS, com métricas operacionais e financeiras.
|
||||
app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
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,
|
||||
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]);
|
||||
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)]));
|
||||
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) };
|
||||
}));
|
||||
} 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) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoProtetico(uid);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, p.valor, p.forma, p.data, p.recebido_nome, o.tipo, o.paciente_nome,
|
||||
(SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome
|
||||
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))
|
||||
ORDER BY p.data DESC, p.created_at DESC LIMIT 200`, [uid, labId]);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 4 — Dados do laboratório (PF/PJ, CPF/CNPJ). GET: dono ou técnico; PATCH: só dono.
|
||||
app.get('/api/protese/lab', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoUsuario(uid);
|
||||
if (!labId) return res.status(404).json({ error: 'Sem laboratório.' });
|
||||
const { rows } = await pool.query("SELECT id, nome_fantasia, documento, COALESCE(pessoa_tipo,'PF') AS pessoa_tipo, owner_id FROM clinicas WHERE id=$1", [labId]);
|
||||
res.json({ ...rows[0], ehDono: rows[0].owner_id === uid });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.patch('/api/protese/lab', 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 edita os dados.' });
|
||||
const b = req.body || {};
|
||||
if (!b.nome_fantasia || !String(b.nome_fantasia).trim()) return res.status(400).json({ error: 'Nome é obrigatório.' });
|
||||
const pessoa = ['PF', 'PJ'].includes(b.pessoa_tipo) ? b.pessoa_tipo : 'PF';
|
||||
await pool.query('UPDATE clinicas SET nome_fantasia=$1, documento=$2, pessoa_tipo=$3 WHERE id=$4',
|
||||
[String(b.nome_fantasia).toUpperCase().trim(), (b.documento || '').trim() || null, pessoa, labId]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 4 — Equipe (técnicos) do laboratório. Apenas o dono gerencia.
|
||||
app.get('/api/protese/lab/equipe', authGuard, async (req, res) => {
|
||||
try {
|
||||
const labId = await labDoProtetico(req.authUser.userId);
|
||||
if (!labId) return res.json([]);
|
||||
const { rows } = await pool.query(
|
||||
"SELECT u.id, u.nome, u.email FROM vinculos v JOIN usuarios u ON u.id=v.usuario_id WHERE v.clinica_id=$1 AND v.role='tecnico_protese' ORDER BY u.nome", [labId]);
|
||||
res.json(rows);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/lab/equipe', authGuard, async (req, res) => {
|
||||
try {
|
||||
const labId = await labDoProtetico(req.authUser.userId);
|
||||
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório adiciona técnicos.' });
|
||||
const email = (req.body?.email || '').toLowerCase().trim();
|
||||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||||
const u = await ensureUsuarioPorEmail(email, req.body?.nome, 'protetico');
|
||||
await pool.query(
|
||||
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'tecnico_protese') ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role='tecnico_protese'",
|
||||
[`v_${u.id}_${labId}`, u.id, labId]);
|
||||
res.json({ success: true, tecnico: { id: u.id, nome: u.nome, email }, created: u.created, tempPassword: u.tempPassword });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.delete('/api/protese/lab/equipe/:userId', authGuard, async (req, res) => {
|
||||
try {
|
||||
const labId = await labDoProtetico(req.authUser.userId);
|
||||
if (!labId) return res.status(403).json({ error: 'Apenas o dono remove técnicos.' });
|
||||
await pool.query("DELETE FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [req.params.userId, labId]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// FASE 5 — Indicadores derivados da captura (Fases 2-4) + Nota ScoreOdonto.
|
||||
// Só dados que JÁ existem: entregas/prazo, garantia (retrabalho), ocorrências RESOLVIDAS
|
||||
// (contraditório respeitado), componentes ausentes, tempo médio. Nota só com volume mínimo.
|
||||
const SCORE_MIN_OS = 3;
|
||||
async function calcularIndicadoresLab(uid, labId) {
|
||||
const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1)) AND o.deleted_at IS NULL`;
|
||||
const { rows: a } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
||||
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
|
||||
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados,
|
||||
COALESCE(AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/86400) FILTER (WHERE status='entregue' AND updated_at IS NOT NULL), 0) AS dias_medio
|
||||
FROM protese_os o WHERE ${esc}`, [uid, labId]);
|
||||
const { rows: oc } = await pool.query(
|
||||
`SELECT COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND ${esc}`, [uid, labId]);
|
||||
const { rows: cp } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM protese_os_componente c JOIN protese_os o ON o.id = c.os_id WHERE c.status_conferencia='ausente' AND ${esc}`, [uid, labId]);
|
||||
const r = a[0], entregues = r.entregues, pct = n => entregues > 0 ? (n / entregues * 100) : 0;
|
||||
const atraso_pct = pct(r.atrasados), retrab_pct = pct(r.garantia), ocor_pct = pct(oc[0].n);
|
||||
let nota = null;
|
||||
if (entregues >= SCORE_MIN_OS) {
|
||||
nota = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
|
||||
nota = Math.round(nota * 10) / 10;
|
||||
}
|
||||
const r1 = n => Math.round(Number(n) * 10) / 10;
|
||||
return {
|
||||
total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados,
|
||||
ocorrencias_resolvidas: oc[0].n, componentes_ausentes: cp[0].n, dias_medio: r1(r.dias_medio),
|
||||
atraso_pct: r1(atraso_pct), retrabalho_pct: r1(retrab_pct), ocorrencia_pct: r1(ocor_pct),
|
||||
nota, min_os: SCORE_MIN_OS,
|
||||
};
|
||||
}
|
||||
app.get('/api/protese/lab/indicadores', 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 vê os indicadores.' });
|
||||
res.json(await calcularIndicadoresLab(uid, labId));
|
||||
} 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]);
|
||||
const { rows } = await pool.query('SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome 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]);
|
||||
const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at', [req.params.id]);
|
||||
const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at, COALESCE(direcao, $2) AS direcao, COALESCE(status_conferencia, $3) AS status_conferencia, conferido_nome, conferido_em FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id, 'enviado', 'pendente']);
|
||||
const { rows: ocorrencias } = await pool.query('SELECT id, componente_id, categoria, descricao, responsavel, resposta, resposta_nome, status, autor_nome, created_at, resolved_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [req.params.id]);
|
||||
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]);
|
||||
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
// Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve.
|
||||
const soLab = isLab && !isClinic;
|
||||
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
|
||||
res.json({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
...os, valor: soLab ? 0 : parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
// Blindagem LGPD: o id do paciente É o CPF — não expor ao provedor de prótese.
|
||||
paciente_id: soLab ? null : os.paciente_id,
|
||||
local_atual: os.local_atual || 'clinica',
|
||||
eventos,
|
||||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
componentes,
|
||||
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||||
custodia,
|
||||
ocorrencias: ocorrencias.map(o => ({
|
||||
...o,
|
||||
fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
})),
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Carrega a OS e valida acesso (lab ou clínica de origem). Reuso nos sub-recursos da OS.
|
||||
async function carregaOSComAcesso(osId, uid) {
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [osId]);
|
||||
if (!rows.length) return { status: 404, error: 'OS não encontrada.' };
|
||||
const os = rows[0];
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||||
if (!isLab && !isClinic) return { status: 403, error: 'Sem acesso a esta OS.' };
|
||||
return { os, isLab, isClinic };
|
||||
}
|
||||
async function logEventoOS(os, uid, observacao, status = 'nota') {
|
||||
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, status, observacao, uid, await actorNome(uid)]);
|
||||
}
|
||||
|
||||
// Remover foto (rastreado no histórico).
|
||||
app.delete('/api/protese/os/fotos/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Foto não encontrada.' });
|
||||
const foto = rows[0];
|
||||
const acc = await carregaOSComAcesso(foto.os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
await pool.query('DELETE FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||||
await logEventoOS(acc.os, uid, 'Foto removida', 'foto');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Componentes/insumos enviados ao laboratório.
|
||||
app.post('/api/protese/os/:id/componentes', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const b = req.body || {};
|
||||
if (!b.nome || !String(b.nome).trim()) return res.status(400).json({ error: 'Nome do componente é obrigatório.' });
|
||||
const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const nome = await actorNome(uid);
|
||||
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
|
||||
const direcao = b.direcao === 'devolvido' ? 'devolvido' : 'enviado';
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome, direcao) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome, direcao]);
|
||||
await logEventoOS(acc.os, uid, `Componente ${direcao}: ${qtd}× ${String(b.nome).trim()}`, 'componente');
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.delete('/api/protese/os/componentes/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
|
||||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
await pool.query('DELETE FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||||
await logEventoOS(acc.os, uid, `Componente removido: ${rows[0].nome}`, 'componente');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Pagamentos/recebimentos da OS (lado='lab' clínica→lab | lado='clinica' paciente→clínica).
|
||||
app.post('/api/protese/os/:id/pagamentos', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const b = req.body || {};
|
||||
const lado = ['lab', 'clinica'].includes(b.lado) ? b.lado : null;
|
||||
if (!lado) return res.status(400).json({ error: "lado deve ser 'lab' ou 'clinica'." });
|
||||
const valor = Number(b.valor);
|
||||
if (!(valor > 0)) return res.status(400).json({ error: 'Valor deve ser maior que zero.' });
|
||||
const id = `ppag_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_pagamento (id, os_id, clinica_id, lado, valor, forma, observacao, data, recebido_por, recebido_nome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8::date,CURRENT_DATE),$9,$10)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, lado, valor, b.forma || null, b.observacao || null, b.data || null, uid, nome]);
|
||||
const rotulo = lado === 'lab' ? 'pago ao laboratório' : 'recebido do paciente';
|
||||
await logEventoOS(acc.os, uid, `Pagamento ${rotulo}: ${valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`, 'pagamento');
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Pagamento não encontrado.' });
|
||||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
await pool.query('DELETE FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
|
||||
await logEventoOS(acc.os, uid, 'Pagamento removido', 'pagamento');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Custódia: registrar que o trabalho mudou de mãos (clínica ↔ laboratório).
|
||||
app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
|
||||
if (!para) return res.status(400).json({ error: "para_local deve ser 'clinica' ou 'laboratorio'." });
|
||||
const de = acc.os.local_atual || 'clinica';
|
||||
if (de === para) return res.status(409).json({ error: `O trabalho já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
|
||||
const id = `pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, req.body?.observacao || null, uid, nome]);
|
||||
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
|
||||
const rotulo = para === 'laboratorio' ? 'Protético retirou o trabalho (foi p/ o laboratório)' : 'Trabalho devolvido à clínica';
|
||||
await logEventoOS(acc.os, uid, rotulo, 'custodia');
|
||||
res.json({ success: true, id, local_atual: para });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Editar campos do trabalho (cor, material, dentes, observações) — lab ou clínica. Auditado.
|
||||
app.patch('/api/protese/os/:id', 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.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser editada.' });
|
||||
const b = req.body || {};
|
||||
const muda = [];
|
||||
for (const campo of ['cor', 'material', 'dentes', 'observacoes']) {
|
||||
if (b[campo] !== undefined) {
|
||||
const novo = b[campo] === null || b[campo] === '' ? null : String(b[campo]);
|
||||
if ((acc.os[campo] || null) !== novo) { await pool.query(`UPDATE protese_os SET ${campo}=$1 WHERE id=$2`, [novo, acc.os.id]); muda.push(`${campo}: ${acc.os[campo] || '—'} → ${novo || '—'}`); }
|
||||
}
|
||||
}
|
||||
if (muda.length) await logEventoOS(acc.os, uid, `Trabalho editado (${muda.join('; ')})`, 'edicao');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Ajuste do valor cobrado do paciente (acréscimo/redução) — SOMENTE clínica. Auditado.
|
||||
app.post('/api/protese/os/:id/ajuste-valor', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica ajusta o valor do paciente.' });
|
||||
const delta = Number(req.body?.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'Informe um valor de ajuste (+ ou −).' });
|
||||
const atual = parseFloat(acc.os.valor || 0);
|
||||
const novo = Math.max(0, atual + delta);
|
||||
await pool.query('UPDATE protese_os SET valor=$1 WHERE id=$2', [novo, acc.os.id]);
|
||||
const sinal = delta >= 0 ? '+' : '−';
|
||||
const obs = req.body?.observacao ? ` · ${String(req.body.observacao).slice(0, 200)}` : '';
|
||||
await logEventoOS(acc.os, uid, `Valor ao paciente ${sinal}${Math.abs(delta).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}${obs} (novo total ${novo.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`, 'valor');
|
||||
res.json({ success: true, valor: novo });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Trocar o trabalho por outro (produto do catálogo do lab OU tipo livre) + motivo. Auditado.
|
||||
app.post('/api/protese/os/:id/trocar', 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.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser trocada.' });
|
||||
const b = req.body || {};
|
||||
if (!b.motivo || !String(b.motivo).trim()) return res.status(400).json({ error: 'Informe o motivo da troca.' });
|
||||
const de = acc.os.tipo;
|
||||
let novoTipo = b.tipo ? String(b.tipo).trim() : null, custo = parseFloat(acc.os.custo || 0), garantia = acc.os.garantia_meses, produtoId = acc.os.produto_id;
|
||||
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, acc.os.protetico_id]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
|
||||
produtoId = pr[0].id; novoTipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantia = Number(pr[0].garantia_meses) || 12;
|
||||
}
|
||||
if (!novoTipo) return res.status(400).json({ error: 'Selecione o novo trabalho.' });
|
||||
await pool.query('UPDATE protese_os SET tipo=$1, produto_id=$2, custo=$3, garantia_meses=$4 WHERE id=$5', [novoTipo, produtoId, custo, garantia, acc.os.id]);
|
||||
await logEventoOS(acc.os, uid, `Trabalho trocado: ${de} → ${novoTipo} · Motivo: ${String(b.motivo).trim()}`, 'troca');
|
||||
res.json({ success: true, tipo: novoTipo });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── FASE 2: rastreabilidade (conferência) + ocorrências (contraditório) ──────
|
||||
const OCORRENCIA_CATS = ['nao_devolvido', 'danificado', 'incompativel', 'parafuso_substituido', 'parafuso_desgastado', 'peca_incorreta', 'ajuste_inadequado', 'falha_fabricacao', 'retrabalho', 'outro'];
|
||||
|
||||
// Conferir um componente (ok | divergente | ausente | pendente).
|
||||
app.post('/api/protese/os/componentes/:id/conferir', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
|
||||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const st = ['ok', 'divergente', 'ausente', 'pendente'].includes(req.body?.status_conferencia) ? req.body.status_conferencia : null;
|
||||
if (!st) return res.status(400).json({ error: 'status_conferencia inválido.' });
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query('UPDATE protese_os_componente SET status_conferencia=$1, conferido_nome=$2, conferido_em=NOW() WHERE id=$3', [st, nome, req.params.id]);
|
||||
await logEventoOS(acc.os, uid, `Componente conferido (${st}): ${rows[0].nome}`, 'componente');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Abrir ocorrência.
|
||||
app.post('/api/protese/os/:id/ocorrencias', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
const b = req.body || {};
|
||||
if (!OCORRENCIA_CATS.includes(b.categoria)) return res.status(400).json({ error: 'Categoria inválida.' });
|
||||
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva a ocorrência.' });
|
||||
const id = `poco_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_ocorrencia (id, os_id, clinica_id, componente_id, categoria, descricao, responsavel, status, autor_id, autor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,'aberta',$8,$9)`,
|
||||
[id, acc.os.id, acc.os.clinica_id, b.componente_id || null, b.categoria, String(b.descricao).trim(), b.responsavel || null, uid, nome]);
|
||||
await logEventoOS(acc.os, uid, `Ocorrência aberta: ${b.categoria}`, 'ocorrencia');
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Responder ocorrência (contraditório) → 'respondida'.
|
||||
app.post('/api/protese/os/ocorrencias/:id/resposta', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
|
||||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida.' });
|
||||
if (!req.body?.resposta || !String(req.body.resposta).trim()) return res.status(400).json({ error: 'Escreva a resposta.' });
|
||||
const nome = await actorNome(uid);
|
||||
await pool.query("UPDATE protese_os_ocorrencia SET resposta=$1, resposta_nome=$2, status=CASE WHEN status='aberta' THEN 'respondida' ELSE status END WHERE id=$3", [String(req.body.resposta).trim(), nome, req.params.id]);
|
||||
await logEventoOS(acc.os, uid, `Ocorrência respondida: ${rows[0].categoria}`, 'ocorrencia');
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Mudar status da ocorrência (em_analise | resolvida | contestada). 'resolvida' é final.
|
||||
app.post('/api/protese/os/ocorrencias/:id/status', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
const novo = req.body?.status;
|
||||
if (!['em_analise', 'resolvida', 'contestada'].includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||||
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
|
||||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||||
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida (final).' });
|
||||
await pool.query("UPDATE protese_os_ocorrencia SET status=$1, resolved_at=CASE WHEN $1='resolvida' THEN NOW() ELSE resolved_at END WHERE id=$2", [novo, req.params.id]);
|
||||
await logEventoOS(acc.os, uid, `Ocorrência ${novo}: ${rows[0].categoria}`, 'ocorrencia');
|
||||
res.json({ success: true, status: novo });
|
||||
} 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;
|
||||
@@ -3997,7 +4451,9 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
// 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.' });
|
||||
// Etapas de produção (recebido→…→finalizado): lab OU clínica de origem podem avançar
|
||||
// (o coordenador da clínica acompanha o trabalho). Auditado por actor no histórico.
|
||||
if (!estadosClinica.includes(novo) && !isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso para avançar 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, finLabId = os.financeiro_lab_id || null;
|
||||
if (novo === 'entregue') {
|
||||
@@ -4057,8 +4513,16 @@ app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async
|
||||
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]);
|
||||
const uid = req.authUser.userId;
|
||||
const nome = await actorNome(uid);
|
||||
const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null;
|
||||
const ocorrenciaId = req.body?.ocorrencia_id || null; // foto de evidência de uma ocorrência
|
||||
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda, ocorrencia_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda, ocorrenciaId]);
|
||||
// Anexar foto entra no histórico (rastreabilidade: quem anexou e quando).
|
||||
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, ocorrenciaId ? 'ocorrencia' : 'foto', ocorrenciaId ? 'Evidência anexada à ocorrência' : (legenda ? `Foto anexada: ${legenda}` : 'Foto anexada'), uid, nome]);
|
||||
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 }); }
|
||||
});
|
||||
@@ -5003,7 +5467,9 @@ async function runMigrations() {
|
||||
)`,
|
||||
// Vinculos: constraint role (inclui protetico — necessário p/ workspace pessoal)
|
||||
`ALTER TABLE vinculos DROP CONSTRAINT IF EXISTS vinculos_role_check`,
|
||||
`ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','donosala','admin']))`,
|
||||
`ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','tecnico_protese','funcionario','donoclinica','donoconsultorio','donosala','admin']))`,
|
||||
// Fase 4 — Provedor de Prótese como empresa: PF (individual) | PJ (laboratório). documento = CPF/CNPJ.
|
||||
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS pessoa_tipo TEXT DEFAULT 'PF'`,
|
||||
// role na tabela usuarios também precisa aceitar 'biomedico' (constraint pré-existente do banco)
|
||||
`ALTER TABLE usuarios DROP CONSTRAINT IF EXISTS usuarios_role_check`,
|
||||
`ALTER TABLE usuarios ADD CONSTRAINT usuarios_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','donosala','admin','superadmin']))`,
|
||||
@@ -5732,6 +6198,84 @@ async function runMigrations() {
|
||||
FROM clinicas c
|
||||
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
|
||||
// ── Profissionalização da OS de prótese ────────────────────────────────────
|
||||
// Fotos com autoria + legenda (quem anexou / o quê).
|
||||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_by TEXT`,
|
||||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_nome TEXT`,
|
||||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS legenda TEXT`,
|
||||
// Componentes/insumos enviados ao laboratório (ex.: implante, pilar, modelo).
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_componente (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
nome TEXT NOT NULL,
|
||||
quantidade INTEGER DEFAULT 1,
|
||||
observacao TEXT,
|
||||
enviado_por TEXT,
|
||||
enviado_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_comp ON protese_os_componente (os_id, created_at)`,
|
||||
// Pagamentos/recebimentos vinculados à OS (dois lados):
|
||||
// lado='lab' → a clínica paga o laboratório (a-receber do lab)
|
||||
// lado='clinica' → o paciente paga a clínica pela prótese
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_pagamento (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
lado TEXT NOT NULL,
|
||||
valor NUMERIC DEFAULT 0,
|
||||
forma TEXT,
|
||||
observacao TEXT,
|
||||
data DATE DEFAULT CURRENT_DATE,
|
||||
recebido_por TEXT,
|
||||
recebido_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
|
||||
// Custódia: onde o trabalho físico está agora (clinica|laboratorio) + log de movimentações
|
||||
// (saber se o protético "pegou" o trabalho ou se está na clínica).
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS local_atual TEXT DEFAULT 'clinica'`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_custodia (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
de_local TEXT,
|
||||
para_local TEXT NOT NULL,
|
||||
etapa TEXT,
|
||||
observacao TEXT,
|
||||
actor_id TEXT,
|
||||
actor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
|
||||
// ── FASE 2: Rastreabilidade de componentes + Ocorrências (com contraditório) ──
|
||||
// Componentes ganham direção (enviado clínica→lab | devolvido lab→clínica) e conferência.
|
||||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS direcao TEXT DEFAULT 'enviado'`,
|
||||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS status_conferencia TEXT DEFAULT 'pendente'`, // pendente|ok|divergente|ausente
|
||||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_nome TEXT`,
|
||||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_em TIMESTAMPTZ`,
|
||||
// Ocorrências técnicas (parafuso trocado, peça danificada, retrabalho…). Contraditório:
|
||||
// aberta → respondida → em_analise → resolvida | contestada. Só resolvidas pesam no score (Fase 5).
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_ocorrencia (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
componente_id TEXT,
|
||||
categoria TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
responsavel TEXT,
|
||||
resposta TEXT,
|
||||
resposta_nome TEXT,
|
||||
status TEXT DEFAULT 'aberta',
|
||||
autor_id TEXT,
|
||||
autor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`,
|
||||
// Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id).
|
||||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
|
||||
|
||||
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
|
||||
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
|
||||
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
|
||||
|
||||
## Conceito
|
||||
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
|
||||
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
|
||||
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
|
||||
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
|
||||
|
||||
```
|
||||
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
|
||||
├── Profissional Individual (PF)
|
||||
├── Laboratório (PJ)
|
||||
└── Laboratório com Equipe (PJ + técnicos)
|
||||
```
|
||||
|
||||
## Modos de operação
|
||||
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
|
||||
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
|
||||
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
|
||||
ocorrências. CTA: "crie sua conta ScoreOdonto".
|
||||
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
|
||||
Catálogo, Notificações, Perfil.
|
||||
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
|
||||
|
||||
## Cadastro/Login
|
||||
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
|
||||
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
|
||||
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
|
||||
|
||||
## Rastreabilidade de componentes (modelo)
|
||||
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
|
||||
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
|
||||
A **ocorrência referencia o componente** (FK opcional).
|
||||
|
||||
## Ocorrências + Contraditório
|
||||
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
|
||||
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
|
||||
categoria, descrição, fotos, evidências, anexos, responsável, status.
|
||||
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
|
||||
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
|
||||
|
||||
## Abas da OS (alvo)
|
||||
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
|
||||
|
||||
## Indicadores (POR ÚLTIMO)
|
||||
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
|
||||
Score/ranking/marketplace. Não codar indicador antes de existir dado.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (5 fases)
|
||||
|
||||
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
|
||||
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
|
||||
(badge na lista e no modal). Destrava o cenário multi-clínica.
|
||||
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
|
||||
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
|
||||
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
|
||||
|
||||
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
|
||||
- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
|
||||
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
|
||||
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
|
||||
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
|
||||
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
|
||||
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
|
||||
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
|
||||
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
|
||||
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
|
||||
|
||||
### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun)
|
||||
- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas,
|
||||
atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`.
|
||||
- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de
|
||||
recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`.
|
||||
Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado − recebido.
|
||||
- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config.
|
||||
- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida).
|
||||
- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato.
|
||||
|
||||
### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun)
|
||||
- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ
|
||||
(já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`.
|
||||
- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail
|
||||
(`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`.
|
||||
- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada
|
||||
usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro
|
||||
(menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono.
|
||||
- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê
|
||||
clientes (0) e leva 403 ao tentar gerenciar equipe.
|
||||
|
||||
### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun)
|
||||
- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das
|
||||
fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia),
|
||||
ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`.
|
||||
- **Nota ScoreOdonto** (0-10): `10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de
|
||||
3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado).
|
||||
- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena
|
||||
internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO.
|
||||
- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a
|
||||
clínica vê a nota no dropdown; "score em formação" abaixo do mínimo.
|
||||
|
||||
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
|
||||
|
||||
### Fora de escopo (futuro)
|
||||
Marketplace **transacional** (propostas/contratação dentro da plataforma) e o Modo 1 (protético sem
|
||||
conta via link seguro `/p/TOKEN`) seguem como evolução — a base de dados/score já os suporta.
|
||||
+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.20 · commit `8676308`**.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.21 · commit `064f36d`**.
|
||||
- **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.20 · `8676308` · 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.21 · `064f36d` · 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.20**), 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.21**), 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.
|
||||
|
||||
+21
-2
@@ -32,6 +32,9 @@ import { ComissoesView } from './views/ComissoesView.tsx';
|
||||
import { GlosasView } from './views/GlosasView.tsx';
|
||||
import { SalaHomeView } from './views/SalaHomeView.tsx';
|
||||
import { LabHomeView } from './views/LabHomeView.tsx';
|
||||
import { LabClientesView } from './views/LabClientesView.tsx';
|
||||
import { LabEquipeView } from './views/LabEquipeView.tsx';
|
||||
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
import { OrcamentosView } from './views/OrcamentosView.tsx';
|
||||
import { ContratosView } from './views/ContratosView.tsx';
|
||||
@@ -87,6 +90,10 @@ export type ViewKey =
|
||||
| 'sala-home'
|
||||
| 'lab-home'
|
||||
| 'lab-bancada'
|
||||
| 'lab-clientes'
|
||||
| 'lab-financeiro'
|
||||
| 'lab-equipe'
|
||||
| 'lab-indicadores'
|
||||
| 'marketplace-profissionais'
|
||||
| 'criar-clinica'
|
||||
| 'rx-config'
|
||||
@@ -139,6 +146,10 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/sala-home': 'sala-home',
|
||||
'/lab-home': 'lab-home',
|
||||
'/lab-bancada': 'lab-bancada',
|
||||
'/lab-clientes': 'lab-clientes',
|
||||
'/lab-financeiro': 'lab-financeiro',
|
||||
'/lab-equipe': 'lab-equipe',
|
||||
'/lab-indicadores': 'lab-indicadores',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
'/criar-clinica': 'criar-clinica',
|
||||
'/rx-config': 'rx-config',
|
||||
@@ -196,6 +207,10 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'sala-home': '/sala-home',
|
||||
'lab-home': '/lab-home',
|
||||
'lab-bancada': '/lab-bancada',
|
||||
'lab-clientes': '/lab-clientes',
|
||||
'lab-financeiro': '/lab-financeiro',
|
||||
'lab-equipe': '/lab-equipe',
|
||||
'lab-indicadores': '/lab-indicadores',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
'criar-clinica': '/criar-clinica',
|
||||
'rx-config': '/rx-config',
|
||||
@@ -295,7 +310,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', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// 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.
|
||||
@@ -314,7 +329,7 @@ const App: React.FC = () => {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
@@ -489,6 +504,10 @@ const App: React.FC = () => {
|
||||
case 'sala-home': return <SalaHomeView />;
|
||||
case 'lab-home': return <LabHomeView />;
|
||||
case 'lab-bancada': return <ProteseView />;
|
||||
case 'lab-clientes': return <LabClientesView tab="clientes" />;
|
||||
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
|
||||
case 'lab-equipe': return <LabEquipeView />;
|
||||
case 'lab-indicadores': return <LabIndicadoresView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
|
||||
case 'rx-config': return <RxConfigView />;
|
||||
|
||||
@@ -63,6 +63,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
|
||||
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
|
||||
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
|
||||
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
@@ -100,7 +104,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
|
||||
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
|
||||
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
|
||||
if (item.id === 'lab-home' || item.id === 'lab-bancada') return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
@@ -108,7 +112,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
|
||||
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
|
||||
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
|
||||
if ((activeWorkspace as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
if ((activeWorkspace as any)?.tipo === 'laboratorio') {
|
||||
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
|
||||
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
|
||||
return ehTecnico
|
||||
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
|
||||
: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
}
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
|
||||
|
||||
@@ -778,13 +778,88 @@ export const HybridBackend = {
|
||||
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) => {
|
||||
uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
if (legenda) fd.append('legenda', legenda);
|
||||
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
|
||||
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();
|
||||
},
|
||||
// Conferência de componente (status: ok | divergente | ausente | pendente)
|
||||
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
|
||||
return await res.json();
|
||||
},
|
||||
// Ocorrências (contraditório)
|
||||
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
|
||||
return await res.json();
|
||||
},
|
||||
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
|
||||
return await res.json();
|
||||
},
|
||||
statusProteseOcorrencia: async (ocId: string, status: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseFoto: async (fotoId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
|
||||
return await res.json();
|
||||
},
|
||||
// Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
|
||||
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseComponente: async (compId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
|
||||
return await res.json();
|
||||
},
|
||||
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
|
||||
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProtesePagamento: async (pagId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
|
||||
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
|
||||
return await res.json();
|
||||
},
|
||||
// Editar campos do trabalho (cor, material, dentes, observações)
|
||||
editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar');
|
||||
return await res.json();
|
||||
},
|
||||
// Ajuste do valor cobrado do paciente (delta + ou −)
|
||||
ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor');
|
||||
return await res.json();
|
||||
},
|
||||
// Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo
|
||||
trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho');
|
||||
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' });
|
||||
@@ -1011,6 +1086,45 @@ export const HybridBackend = {
|
||||
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
|
||||
},
|
||||
|
||||
// Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos.
|
||||
getLabClientes: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/clientes`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getLabPagamentos: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
// 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`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar');
|
||||
return await res.json();
|
||||
},
|
||||
getLabEquipe: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
addLabTecnico: async (body: { email: string; nome?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico');
|
||||
return await res.json();
|
||||
},
|
||||
removeLabTecnico: async (userId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover');
|
||||
return await res.json();
|
||||
},
|
||||
// Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados)
|
||||
getLabIndicadores: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
|
||||
// Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade.
|
||||
getSalaReservas: async (salaId: string): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/salas/${salaId}/reservas`);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } 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('/');
|
||||
|
||||
// Á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.
|
||||
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 [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 : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
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);
|
||||
|
||||
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">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
)}
|
||||
|
||||
{aba === 'financeiro' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } 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';
|
||||
|
||||
// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos.
|
||||
export const LabEquipeView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [dados, setDados] = useState<any>(null);
|
||||
const [equipe, setEquipe] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [nome, setNome] = useState('');
|
||||
const [cred, setCred] = useState<{ email: string; senha: string } | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]);
|
||||
setDados(d || {});
|
||||
setEquipe(Array.isArray(e) ? e : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const salvarDados = async () => {
|
||||
if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const addTecnico = async () => {
|
||||
if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined });
|
||||
toast.success('TÉCNICO ADICIONADO.');
|
||||
if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword });
|
||||
setEmail(''); setNome(''); carregar();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const remover = async (u: any) => {
|
||||
if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return;
|
||||
try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
|
||||
if (loading || !dados) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
const ehPJ = dados.pessoa_tipo === 'PJ';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="LABORATÓRIO & EQUIPE">
|
||||
<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>
|
||||
|
||||
{/* Dados do provedor */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['PF', 'PJ'] as const).map(t => (
|
||||
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
|
||||
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
|
||||
<input value={dados.nome_fantasia || ''} onChange={e => setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
|
||||
<input value={dados.documento || ''} onChange={e => setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
|
||||
</div>
|
||||
|
||||
{/* Equipe */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção — sem acesso a clientes, financeiro ou valores do paciente.</p>
|
||||
|
||||
{cred && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
|
||||
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
|
||||
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
|
||||
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{equipe.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
|
||||
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
|
||||
</div>
|
||||
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto.
|
||||
export const LabIndicadoresView: React.FC = () => {
|
||||
const [ind, setInd] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
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">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
|
||||
const temNota = ind.nota !== null && ind.nota !== undefined;
|
||||
const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="INDICADORES 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 shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Nota ScoreOdonto */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
|
||||
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
|
||||
<Award size={18} />
|
||||
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
||||
{temNota ? (
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação — mínimo de {ind.min_os} entregas para gerar a nota.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
|
||||
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
|
||||
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
|
||||
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
|
||||
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
|
||||
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
|
||||
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). Só ocorrências <b>resolvidas</b> (após contraditório) entram — abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -488,7 +488,7 @@ export const LancarGTO: React.FC = () => {
|
||||
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)' : ''}</option>)}
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
|
||||
+519
-66
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check } from 'lucide-react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -7,6 +7,11 @@ 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 dataHoraBR = (d: string) => {
|
||||
if (!d) return '';
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? dataBR(d) : dt.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
|
||||
@@ -17,7 +22,6 @@ const STATUS_COR: Record<string, string> = {
|
||||
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>
|
||||
@@ -117,7 +121,7 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
<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>)}
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
|
||||
</div>
|
||||
@@ -176,6 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
const confirm = useConfirm();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas');
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
||||
@@ -190,14 +195,6 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
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);
|
||||
@@ -216,100 +213,552 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const moverCustodia = async (para: 'clinica' | 'laboratorio') => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.moverProteseCustodia(id, para); toast.success(para === 'laboratorio' ? 'TRABALHO COM O LABORATÓRIO.' : 'TRABALHO NA CLÍNICA.'); carregar(); onChanged(); }
|
||||
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);
|
||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||
|
||||
// Recibo imprimível de uma etapa (comprovante que circula com o trabalho).
|
||||
// formato 'termica' (bobina 80mm) | 'laser' (folha A4). Dados preenchidos da OS.
|
||||
const imprimirCupom = (etapa: string, formato: 'termica' | 'laser') => {
|
||||
const comps = (os.componentes || []).map((c: any) => `${c.quantidade}× ${c.nome}`).join(', ') || '—';
|
||||
const termica = formato === 'termica';
|
||||
const page = termica ? '@page{size:80mm auto;margin:3mm}body{width:72mm;font-size:11px}'
|
||||
: '@page{size:A4;margin:16mm}body{max-width:520px;font-size:12px}';
|
||||
const sign = termica
|
||||
? '.sign{margin-top:24px}.sign div{border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555;margin-top:18px}'
|
||||
: '.sign{margin-top:34px;display:flex;justify-content:space-between;gap:16px}.sign div{flex:1;border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555}';
|
||||
const w = window.open('', '_blank', termica ? 'width=320,height=640' : 'width=560,height=720'); if (!w) return;
|
||||
w.document.write(`<html><head><title>Recibo ${STATUS_LABEL[etapa] || etapa}</title><style>
|
||||
*{font-family:Arial,Helvetica,sans-serif;color:#111;box-sizing:border-box}body{margin:0;padding:10px}
|
||||
h1{font-size:${termica ? 13 : 16}px;margin:0 0 2px}.muted{color:#555;font-size:${termica ? 9 : 10}px}
|
||||
.et{background:#111;color:#fff;padding:6px 8px;border-radius:5px;font-weight:bold;text-align:center;text-transform:uppercase;margin:10px 0;font-size:${termica ? 11 : 13}px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:6px}td{padding:3px 0;vertical-align:top}
|
||||
.lbl{color:#555;text-transform:uppercase;font-size:9px;width:40%}
|
||||
.hr{border-top:1px dashed #999;margin:9px 0}${sign}${page}</style></head><body>
|
||||
<h1>ORDEM DE PRÓTESE</h1><div class="muted">OS ${os.id}<br>${new Date().toLocaleString('pt-BR')}</div>
|
||||
<div class="et">Etapa: ${STATUS_LABEL[etapa] || etapa}</div>
|
||||
<table>
|
||||
<tr><td class="lbl">Paciente</td><td><b>${os.paciente_nome || '—'}</b></td></tr>
|
||||
<tr><td class="lbl">Trabalho</td><td>${os.tipo || '—'}</td></tr>
|
||||
<tr><td class="lbl">Dentista</td><td>${os.dentista_nome || '—'}</td></tr>
|
||||
<tr><td class="lbl">Laboratório</td><td>${os.protetico_nome || '—'}</td></tr>
|
||||
<tr><td class="lbl">Dentes</td><td>${os.dentes || '—'}</td></tr>
|
||||
<tr><td class="lbl">Material / Cor</td><td>${os.material || '—'} / ${os.cor || '—'}</td></tr>
|
||||
<tr><td class="lbl">Componentes</td><td>${comps}</td></tr>
|
||||
<tr><td class="lbl">Prazo</td><td>${os.prazo_entrega ? dataBR(os.prazo_entrega) : '—'}</td></tr>
|
||||
<tr><td class="lbl">Local atual</td><td>${noLab ? 'Laboratório' : 'Clínica'}</td></tr>
|
||||
</table><div class="hr"></div>
|
||||
${os.observacoes ? `<div class="muted">Obs.: ${os.observacoes}</div>` : ''}
|
||||
<div class="sign"><div>Entregue por</div><div>Recebido por</div></div>
|
||||
</body></html>`);
|
||||
w.document.close(); w.focus(); setTimeout(() => w.print(), 250);
|
||||
};
|
||||
|
||||
const idxAtual = FLUXO_OS.indexOf(os.status);
|
||||
|
||||
const TABS = [
|
||||
{ id: 'etapas', label: 'Etapas' },
|
||||
{ id: 'itens', label: 'Itens & Valores' },
|
||||
{ id: 'monitor', label: 'Monitoramento' },
|
||||
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
|
||||
{ id: 'timeline', label: 'Linha do tempo' },
|
||||
] as const;
|
||||
|
||||
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>
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge s={os.status} />
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
</div>
|
||||
{/* Abas */}
|
||||
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{tab === 'etapas' && (
|
||||
<div className="space-y-5">
|
||||
{/* Clínica de origem — crítico no multi-clínica (de qual clínica veio a OS). */}
|
||||
{modo === 'bancada' && os.clinica_nome && (
|
||||
<div className="bg-blue-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs font-black text-blue-700 uppercase"><Building2 size={14} /> Clínica: {os.clinica_nome}</div>
|
||||
)}
|
||||
<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>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> Prazo {dataBR(os.prazo_entrega)}</span>}
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${noLab ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</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">
|
||||
<EtapasStepper os={os} />
|
||||
{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 sm:grid-cols-3 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>}
|
||||
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor cobrado</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>
|
||||
))}
|
||||
{/* Etapas do trabalho: avançar + imprimir recibo (térmica/laser) por etapa */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Etapas do trabalho</p>
|
||||
<div className="space-y-1.5">
|
||||
{FLUXO_OS.map((s, i) => {
|
||||
const done = i < idxAtual, atual = i === idxAtual;
|
||||
const ev = (os.eventos || []).find((e: any) => e.status === s);
|
||||
// Etapas livres: pode ir para qualquer uma (pular/repetir/voltar), exceto 'entregue' (timeline).
|
||||
const acaoLabel = atual ? 'Repetir' : done ? 'Voltar' : 'Ir';
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-2 bg-gray-50 rounded-xl px-3 py-2">
|
||||
{done ? <CheckCircle2 size={16} className="text-teal-600 shrink-0" /> : atual ? <div className="w-4 h-4 rounded-full bg-violet-600 shrink-0" /> : <Circle size={16} className="text-gray-300 shrink-0" />}
|
||||
<span className={`text-xs font-black uppercase ${atual ? 'text-violet-700' : done ? 'text-gray-700' : 'text-gray-400'}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[10px] text-gray-400">{ev ? dataBR(ev.created_at) : ''}</span>
|
||||
<div className="flex-1" />
|
||||
{!finalizado && s !== 'entregue' && <button disabled={busy} onClick={() => mudar(s)} className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase disabled:opacity-50 ${atual ? 'border border-violet-200 text-violet-700 hover:bg-violet-50' : 'bg-violet-600 text-white'}`}>{acaoLabel}</button>}
|
||||
{!finalizado && s === 'entregue' && <span className="text-[9px] text-amber-500 font-black uppercase">aba Linha do tempo</span>}
|
||||
<button onClick={() => imprimirCupom(s, 'termica')} title="Imprimir em impressora térmica (bobina 80mm)" className="p-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white"><Printer size={13} /></button>
|
||||
<button onClick={() => imprimirCupom(s, 'laser')} title="Imprimir em impressora laser (folha A4)" className="px-2 py-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white text-[9px] font-black uppercase">A4</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-300 mt-1.5 uppercase font-bold flex items-center gap-1"><Printer size={10} /> = térmica 80mm · A4 = laser · etapas podem ser repetidas/puladas</p>
|
||||
</div>
|
||||
{!finalizado && <AjustesSecao os={os} modo={modo} onChanged={carregar} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
{tab === 'timeline' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><History size={12} /> Linha do tempo</p>
|
||||
<div className="space-y-2.5">
|
||||
{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 key={i} className="flex items-start gap-2.5 text-xs">
|
||||
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : ev.status === 'custodia' ? 'bg-blue-400' : 'bg-violet-400'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
|
||||
<span className="text-gray-400 block text-[11px]">{dataHoraBR(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>}
|
||||
{!finalizado && modo === 'clinica' && (
|
||||
<div className="pt-3 border-t border-gray-100 space-y-2">
|
||||
{!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 (aba Itens) antes de entregar</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>
|
||||
)}
|
||||
|
||||
{tab === 'itens' && (
|
||||
<div className="space-y-5">
|
||||
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
|
||||
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Ajustes do trabalho (cor/material, valor ao paciente, troca) ─────
|
||||
const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: () => void }> = ({ os, modo, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [cor, setCor] = useState(os.cor || '');
|
||||
const [material, setMaterial] = useState(os.material || '');
|
||||
const [delta, setDelta] = useState('');
|
||||
const [obsVal, setObsVal] = useState('');
|
||||
const [trocaOpen, setTrocaOpen] = useState(false);
|
||||
const [novoTrab, setNovoTrab] = useState('');
|
||||
const [motivo, setMotivo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const salvarCM = async () => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.editarProteseOS(os.id, { cor: cor.trim(), material: material.trim() }); toast.success('TRABALHO ATUALIZADO.'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const ajustar = async (sinal: number) => {
|
||||
const v = parseFloat(delta);
|
||||
if (!(v > 0)) { toast.error('INFORME O VALOR DO AJUSTE.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.ajustarValorProtese(os.id, sinal * v, obsVal.trim() || undefined); toast.success('VALOR DO PACIENTE AJUSTADO.'); setDelta(''); setObsVal(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const trocar = async () => {
|
||||
if (!novoTrab.trim() || !motivo.trim()) { toast.error('INFORME O NOVO TRABALHO E O MOTIVO.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.trocarTrabalhoProtese(os.id, { tipo: novoTrab.trim(), motivo: motivo.trim() }); toast.success('TRABALHO TROCADO.'); setTrocaOpen(false); setNovoTrab(''); setMotivo(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Pencil size={12} /> Ajustes do trabalho</p>
|
||||
{/* Cor / material */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={cor} onChange={e => setCor(e.target.value)} placeholder="Cor (ex.: A2)" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<input value={material} onChange={e => setMaterial(e.target.value)} placeholder="Material" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button disabled={busy} onClick={salvarCM} className="px-3 py-1.5 rounded-lg bg-gray-700 text-white text-[11px] font-black uppercase disabled:opacity-50">Salvar cor / material</button>
|
||||
|
||||
{/* Ajuste de valor ao paciente — só clínica (protético não vê) */}
|
||||
{modo === 'clinica' && (
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<p className="text-[10px] font-black text-gray-500 uppercase">Ajustar valor do paciente {Number(os.valor) > 0 ? `· atual ${fmtBRL(os.valor)}` : ''}</p>
|
||||
<div className="flex gap-2">
|
||||
<input value={delta} onChange={e => setDelta(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-24 p-2 bg-white border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
<input value={obsVal} onChange={e => setObsVal(e.target.value)} placeholder="Motivo (ex.: pino extra)" className="flex-1 p-2 bg-white border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => ajustar(1)} className="flex-1 py-1.5 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase disabled:opacity-50">+ Acrescentar</button>
|
||||
<button disabled={busy} onClick={() => ajustar(-1)} className="flex-1 py-1.5 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">− Reduzir</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trocar trabalho */}
|
||||
<button onClick={() => setTrocaOpen(o => !o)} className="px-3 py-1.5 rounded-lg border border-violet-200 text-violet-700 text-[11px] font-black uppercase hover:bg-violet-50 flex items-center gap-1.5"><RefreshCw size={12} /> Solicitar troca de trabalho</button>
|
||||
{trocaOpen && (
|
||||
<div className="bg-violet-50 rounded-xl p-3 space-y-2">
|
||||
<input value={novoTrab} onChange={e => setNovoTrab(e.target.value)} placeholder="Novo trabalho (ex.: PPR metálica)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
|
||||
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da troca (obrigatório)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
|
||||
<button disabled={busy} onClick={trocar} className="w-full py-2 rounded-lg bg-violet-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Confirmar troca</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ────────────
|
||||
const OCOR_CATS: Record<string, string> = {
|
||||
nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível',
|
||||
parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta',
|
||||
ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro',
|
||||
};
|
||||
const OCOR_STATUS_COR: Record<string, string> = {
|
||||
aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700',
|
||||
resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cat, setCat] = useState('danificado');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [resp, setResp] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [respostas, setRespostas] = useState<Record<string, string>>({});
|
||||
const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado';
|
||||
const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase());
|
||||
|
||||
const abrir = async () => {
|
||||
if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); }
|
||||
catch (e) { err(e); } finally { setBusy(false); }
|
||||
};
|
||||
const responder = async (id: string) => {
|
||||
const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); }
|
||||
};
|
||||
const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } };
|
||||
const anexar = async (id: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]; if (!f) return; setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
|
||||
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="bg-red-50 rounded-xl p-3 space-y-2">
|
||||
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
|
||||
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
|
||||
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
|
||||
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{(os.ocorrencias || []).map((o: any) => (
|
||||
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
|
||||
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
|
||||
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{o.descricao}</p>
|
||||
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
|
||||
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
|
||||
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
|
||||
{o.status !== 'resolvida' && (
|
||||
<div className="space-y-1.5 pt-1 border-t border-gray-50">
|
||||
<div className="flex gap-1.5">
|
||||
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
|
||||
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
|
||||
</div>
|
||||
))}
|
||||
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-300 uppercase font-bold">Só ocorrências resolvidas pesarão em indicadores (futuro).</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
||||
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><MapPin size={12} /> Onde está o trabalho</p>
|
||||
<div className={`rounded-2xl p-5 text-center ${noLab ? 'bg-violet-50' : 'bg-blue-50'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl mx-auto flex items-center justify-center text-white ${noLab ? 'bg-violet-600' : 'bg-blue-600'}`}>{noLab ? <FlaskConical size={26} /> : <Building2 size={26} />}</div>
|
||||
<p className={`mt-3 font-black uppercase text-sm ${noLab ? 'text-violet-700' : 'text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">{noLab ? 'O protético está com o trabalho' : 'O trabalho está na clínica'}</p>
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy || noLab} onClick={() => onMover('laboratorio')} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><FlaskConical size={14} /> Protético retirou</button>
|
||||
<button disabled={busy || !noLab} onClick={() => onMover('clinica')} className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><Building2 size={14} /> Devolveu à clínica</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Movimentações</p>
|
||||
<div className="space-y-2">
|
||||
{(os.custodia || []).map((c: any) => (
|
||||
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-200 text-gray-600">{c.de_local === 'clinica' ? 'Clínica' : 'Lab'} → {c.para_local === 'clinica' ? 'Clínica' : 'Lab'}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-1 truncate">{c.actor_nome} · {dataHoraBR(c.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(os.custodia || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Sem movimentações registradas.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Stepper visual das etapas da OS ─────────────────────────────────────────
|
||||
const FLUXO_OS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
|
||||
const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
|
||||
if (os.status === 'cancelado') {
|
||||
return <div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase flex items-center gap-2"><X size={14} /> OS cancelada</div>;
|
||||
}
|
||||
const idxAtual = FLUXO_OS.indexOf(os.status);
|
||||
const dataDe = (s: string) => { const ev = os.eventos?.find((e: any) => e.status === s); return ev ? dataBR(ev.created_at) : ''; };
|
||||
return (
|
||||
<div className="flex items-start overflow-x-auto pb-1">
|
||||
{FLUXO_OS.map((s, i) => {
|
||||
const done = i < idxAtual, atual = i === idxAtual;
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex flex-col items-center min-w-[58px]">
|
||||
{done ? <CheckCircle2 size={20} className="text-teal-600" />
|
||||
: atual ? <div className="w-5 h-5 rounded-full bg-violet-600 ring-4 ring-violet-100" />
|
||||
: <Circle size={20} className="text-gray-200" />}
|
||||
<span className={`text-[8px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-violet-700' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[8px] text-gray-300 leading-tight">{dataDe(s)}</span>
|
||||
</div>
|
||||
{i < FLUXO_OS.length - 1 && <div className={`flex-1 h-0.5 mt-2.5 min-w-[10px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
|
||||
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
|
||||
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [nome, setNome] = useState('');
|
||||
const [qtd, setQtd] = useState('1');
|
||||
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const add = async () => {
|
||||
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
|
||||
<div className="space-y-1.5">
|
||||
{(os.componentes || []).map((c: any) => (
|
||||
<div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
|
||||
<span className="font-black text-gray-700">{c.quantidade}×</span>
|
||||
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
|
||||
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
|
||||
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
|
||||
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
|
||||
<option value="enviado">Enviado</option>
|
||||
<option value="devolvido">Devolvido</option>
|
||||
</select>
|
||||
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Fotos (anexar com legenda, autor, remover) ───────────────────────
|
||||
const FotosSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [legenda, setLegenda] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(os.id, file, legenda.trim() || undefined); setLegenda(''); onChanged(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); } finally { setBusy(false); e.target.value = ''; }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProteseFoto(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Camera size={12} /> Fotos do trabalho</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{(os.fotos || []).map((f: any) => (
|
||||
<div key={f.id} className="rounded-lg border border-gray-100 overflow-hidden group relative">
|
||||
<a href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-full h-24 object-cover" /></a>
|
||||
{podeEditar && <button onClick={() => del(f.id)} className="absolute top-1 right-1 bg-white/90 rounded-md p-1 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={12} /></button>}
|
||||
<div className="px-2 py-1">
|
||||
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate">{f.legenda}</p>}
|
||||
<p className="text-[9px] text-gray-400 truncate">{f.uploaded_nome || '—'} · {dataBR(f.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(os.fotos || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase col-span-full">Nenhuma foto anexada.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input value={legenda} onChange={e => setLegenda(e.target.value)} placeholder="Legenda da próxima foto (opcional)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<label className={`flex items-center gap-1.5 px-3 rounded-lg border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<ImagePlus size={14} /> Anexar
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Pagamentos (dois lados) ──────────────────────────────────────────
|
||||
const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEditar: boolean; onChanged: () => void }> = ({ os, modo, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [lado, setLado] = useState<'lab' | 'clinica'>(modo === 'bancada' ? 'lab' : 'clinica');
|
||||
const [valor, setValor] = useState('');
|
||||
const [forma, setForma] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pags = os.pagamentos || [];
|
||||
const totalLab = pags.filter((p: any) => p.lado === 'lab').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
|
||||
const totalClinica = pags.filter((p: any) => p.lado === 'clinica').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
|
||||
const add = async () => {
|
||||
const v = parseFloat(valor);
|
||||
if (!(v > 0)) { toast.error('INFORME UM VALOR.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.addProtesePagamento(os.id, { lado, valor: v, forma: forma.trim() || undefined }); setValor(''); setForma(''); onChanged(); }
|
||||
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()); } };
|
||||
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>
|
||||
{/* O protético (modo bancada) NÃO vê o que o paciente paga — só o que recebe do lab. */}
|
||||
<div className={`grid ${modo === 'bancada' ? 'grid-cols-1' : 'grid-cols-2'} gap-2 mb-2`}>
|
||||
{modo === 'clinica' && <div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>}
|
||||
<div className="bg-violet-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-violet-600 uppercase">Pago ao laboratório</p><p className="text-sm font-black text-violet-700">{fmtBRL(totalLab)}</p></div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{pags.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase ${p.lado === 'lab' ? 'bg-violet-100 text-violet-700' : 'bg-green-100 text-green-700'}`}>{p.lado === 'lab' ? 'Lab' : 'Paciente'}</span>
|
||||
<span className="font-black text-gray-700">{fmtBRL(p.valor)}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-1 truncate">{p.forma ? `${p.forma} · ` : ''}{p.recebido_nome} · {dataBR(p.data)}</span>
|
||||
{podeEditar && <button onClick={() => del(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
||||
</div>
|
||||
))}
|
||||
{pags.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum pagamento registrado.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{modo === 'clinica' ? (
|
||||
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
|
||||
<option value="clinica">Paciente</option>
|
||||
<option value="lab">Lab</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="p-2 bg-violet-50 rounded-lg text-xs font-black text-violet-700 uppercase flex items-center">Lab</span>
|
||||
)}
|
||||
<input value={valor} onChange={e => setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
<input value={forma} onChange={e => setForma(e.target.value)} placeholder="Forma (pix...)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -467,6 +916,10 @@ export const ProteseView: React.FC = () => {
|
||||
{modo === 'bancada' ? (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. */}
|
||||
{modo === 'bancada' && os.clinica_nome && (
|
||||
<p className="text-[10px] font-black text-blue-600 uppercase truncate flex items-center gap-1"><Building2 size={10} /> {os.clinica_nome}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}
|
||||
|
||||
Reference in New Issue
Block a user