Compare commits

..

13 Commits

Author SHA1 Message Date
VPS 4 Builder 064f36d614 docs(protese): marca Fase 2 como implementada/validada + nota do bug pacientenome
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:35:46 +02:00
VPS 4 Builder 50e1483ad5 feat(protese): Fase 2 frontend — área do Laboratório (painel + bancada)
Workspace tipo='laboratorio' ganha menu próprio (PAINEL DO LAB + BANCADA),
isolado das telas clínicas — espelha o tratamento de tipo='sala'. lab-bancada
reaproveita ProteseView (modo bancada); lab-home é o novo LabHomeView (fila por
status, atrasadas, entregues e faturamento do mês a partir do que já existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder a1c203ced5 feat(protese): Fase 2 backend — Laboratório como workspace de 1ª classe
- Workspace do protético passa a ser tipo='laboratorio' (re-tag dos existentes +
  novos nascem assim no /api/register). Área de gestão própria, como a clínica.
- Roteamento por laboratorio_id: OS nascem com o lab (POST /os e GTO→OS); a
  Bancada filtra por laboratorio_id com fallback por protetico_id (OS legadas).
  Helper labDoProtetico(). Backfill aditivo + índice.
- Receita espelhada (2.1): a entrega lança RECEITA no workspace do lab
  (clinica_id=laboratorio_id, valor=os.custo), espelho da DESPESA da clínica —
  idempotente (financeiro_lab_id), isolado em try/catch.

fix: corrige coluna `paciente_nome`→`pacientenome` no INSERT da DESPESA de prótese
(bug pré-existente da Fase 1, em produção): a entrega de OS com custo>0 abortava
com "column paciente_nome does not exist". A mesma correção vale para a RECEITA nova.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder 9ed8bc25bd docs(verdade-hoje): produção em v1.0.20 (8676308)
build-and-promote / build (push) Successful in 53s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:11:56 +02:00
VPS 4 Builder 8676308325 docs(salas): registra fix de menu + feature de manutenção/operador na análise da recepção
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 59s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:25:09 +02:00
VPS 4 Builder 983582f066 feat(salas): acesso operacional do funcionário à sala + bloqueio p/ manutenção
Funcionário/membro com vínculo na clínica dona da sala passa a ter a sala como
workspace de acesso OPERACIONAL (papel_sala='operador'): vê a agenda da sala e
bloqueia horário para manutenção, sem locação nem financeiro. O Painel da Sala
detecta o modo operador e esconde recebíveis/relatório/baixa, mostrando só a
agenda + ação de bloqueio.

Bloqueio de manutenção: reserva tipo='manutencao' (coluna nova, default
'locacao'), valor 0, nasce 'confirmada' (sem aprovação) e NÃO gera recebível.
Reaproveita o anti-conflito de horário da sala (409 em sobreposição) e o
cancelamento por solicitante/unidade. A agenda renderiza o bloqueio em cinza.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:23:36 +02:00
VPS 4 Builder 718a7886e1 docs(protese): modelo Fase 2 (área do protético) + análise área recepção/TC
- FASE2-PROTETICO-AREA: Laboratório como workspace de 1ª classe (tipo='laboratorio'),
  roteamento por laboratorio_id, área de gestão própria, financeiro espelhado.
- FASE2-IMPL-2.0-2.1: detalhamento de implementação (migrações, backfill, bancada,
  receita espelhada, menu da área).
- ANALISE-AREA-RECEPCAO-TC: gap de permissões da recepção fundamentado em modelos
  validados (Treatment Coordinator / RBAC / Lab Case Manager).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 9b0ba65eca fix(salas): restringe MINHAS/ALUGAR SALAS a papéis de locação
Os menus de locador (MINHAS SALAS) e locatário (ALUGAR SALAS) eram exibidos a
"todos menos superadmin", vazando para funcionário e paciente — papéis que não
são titulares de sala e não tomam decisões de locação. Passa a exibir apenas a
papéis que podem ser titulares (donosala/donoclinica/donoconsultorio/admin/
dentista/biomedico/protetico).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 553efb003b fix(rbac): expõe PRÓTESES e CONTRATOS no editor de cargos
PAGINAS_RBAC não listava 'proteses' nem 'contratos', então essas páginas não
eram concedíveis por nenhum cargo (nem via "MARCAR TODAS"/GERENTE, que derivam
de TODAS_PAGINAS = flatMap de PAGINAS_RBAC). Resultado: era impossível um dono
dar acesso à área de Prótese a um funcionário pela interface.

Adiciona ambas ao grupo ATENDIMENTO e inclui proteses/lancar-gto nos templates
RECEPÇÃO e AUXILIAR (alinhado ao papel de Treatment Coordinator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 321ca7e127 feat(protese): Fase 1 — finalizar GTO abre OS de prótese na Bancada
Itens de GTO ligados a um produto do catálogo do protético geram, na
finalização da GTO, uma OS de prótese (status 'solicitado') na Bancada do
laboratório — idempotente por gto_item_id e isolado em try/catch para nunca
derrubar o finalize. Migrações aditivas (gto_items.produto_id/protetico_id,
protese_os.gto_item_id, re-tag de especialidades para area='protese').

No Lançar GTO, a especialidade de prótese troca o seletor de procedimentos
pelo fluxo laboratório → produto → valor → observação. Busca de pacientes
passa a ser server-side com debounce (antes filtrava só a 1ª página).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder d005fe77a9 feat(clinicas): permite criar unidades adicionais (multi-clínica)
- ClinicasView: botão '+ Nova clínica/consultório' + modal de criação,
  gateado pelo papel da conta (donoclinica/donoconsultorio/admin).
- Corrige applyWorkspace destrutivo no onboarding: usa refreshWorkspaces
  (acrescenta a unidade à lista, em vez de sobrescrever as existentes).

Antes não havia caminho de UI para criar uma 2ª clínica fora do
onboarding; o backend já suportava N clínicas. Validado em DEV (8020).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:46:08 +02:00
VPS 4 Builder 0ade8027f5 docs(verdade-hoje): atualiza versão corrente para v1.0.19 (715b2dc)
Alinha o documento ao git HEAD/produção atual. Mantém intactas as
menções históricas (validação em v1.0.16, colisão de tag v1.0.14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:28:35 +02:00
VPS 4 Builder 8f9177a1c1 docs(status): marca fila A concluída — produção em v1.0.19 (715b2dc)
build-and-promote / build (push) Successful in 52s
build-and-promote / promote (push) Has been skipped
Atualiza STATUS-SEGURANCA-E-PRODUCAO.md: cabeçalho (prod v1.0.19), nova
seção "Fila A concluída" (push → fix CI → build → tag/promote → backup →
verificação), seção A marcada como feita, commits agora publicados e
"Como retomar" com pendências restantes (B + A.2) e rollback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:36:50 +02:00
15 changed files with 1342 additions and 92 deletions
+158 -13
View File
@@ -518,7 +518,20 @@ async function listarWorkspaces(userId, userRole) {
id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala', id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala',
cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null,
})); }));
return [...clinicas, ...salaWs]; // Salas das clínicas em que o usuário tem vínculo (mas NÃO é dono): acesso OPERACIONAL —
// ver a agenda da sala e bloquear horário para manutenção, sem locação/financeiro.
const { rows: salasOp } = await pool.query(`
SELECT DISTINCT s.id, s.nome, s.cidade, s.estado
FROM salas s
JOIN vinculos v ON v.clinica_id = s.clinica_id AND v.usuario_id = $1
WHERE s.clinica_id IS NOT NULL AND s.owner_usuario_id <> $1
ORDER BY s.nome`, [userId]);
const salaOpWs = salasOp.map(s => ({
id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'funcionario', tipo: 'sala',
cidade: s.cidade, estado: s.estado, papel_sala: 'operador',
funcao_id: null, funcao_nome: null, permissoes: null, nivel: null,
}));
return [...clinicas, ...salaWs, ...salaOpWs];
} }
// Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido). // Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido).
@@ -621,9 +634,11 @@ app.post('/api/register', async (req, res) => {
: userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}` : userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}`
: nome.trim(); : nome.trim();
const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase(); const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase();
// Fase 2: o workspace do protético é um LABORATÓRIO (área de gestão de 1ª classe).
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
await pool.query( await pool.query(
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, 'pessoal', $3)`, `INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, $3, $4)`,
[wsId, wsNome, userId] [wsId, wsNome, wsTipo, userId]
); );
await pool.query( await pool.query(
'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)', 'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)',
@@ -3769,6 +3784,15 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) =>
const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado']; const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado'];
// Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo). // Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo).
// O workspace 'laboratorio' do protético (1 por protético nesta fase). Usado para escopar a
// bancada e as OS por laboratório (empresa), além do protetico_id (pessoa) que segue como fallback.
async function labDoProtetico(proteticoId) {
if (!proteticoId) return null;
const { rows } = await pool.query(
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' LIMIT 1", [proteticoId]);
return rows[0]?.id || null;
}
async function proteseOsAccesso(os, uid) { async function proteseOsAccesso(os, uid) {
if (!os) return { isLab: false, isClinic: false }; if (!os) return { isLab: false, isClinic: false };
const isLab = !!uid && os.protetico_id === uid; const isLab = !!uid && os.protetico_id === uid;
@@ -3891,10 +3915,11 @@ app.post('/api/protese/os', tenantGuard, async (req, res) => {
if (ehGarantia) custo = 0; if (ehGarantia) custo = 0;
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const uid = req.authUser?.userId; const uid = req.authUser?.userId;
const labId = await labDoProtetico(b.protetico_id); // Fase 2: escopo por laboratório (empresa)
await pool.query( await pool.query(
`INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by) `INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,'solicitado',$24)`,
[id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null, [id, req.clinicaId, b.protetico_id, labId, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null,
b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null, b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null,
tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses, tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses,
b.material || null, b.cor || null, b.dentes || null, b.observacoes || null, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
@@ -3926,8 +3951,10 @@ app.get('/api/protese/os', tenantGuard, async (req, res) => {
app.get('/api/protese/bancada', authGuard, async (req, res) => { app.get('/api/protese/bancada', authGuard, async (req, res) => {
try { try {
const uid = req.authUser.userId; const uid = req.authUser.userId;
const vals = [uid]; const labId = await labDoProtetico(uid); // Fase 2: a bancada é do LABORATÓRIO (empresa)
let where = 'protetico_id=$1 AND deleted_at IS NULL'; 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}`; } 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( 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 *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
@@ -3972,7 +3999,7 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca 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.' }); if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' });
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia). // 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; let garantiaAte = null, finId = os.financeiro_id || null, finLabId = os.financeiro_lab_id || null;
if (novo === 'entregue') { if (novo === 'entregue') {
const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]); const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]);
if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' }); if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' });
@@ -3983,13 +4010,27 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') { if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query( await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao) `INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, pacientenome, origem, centro_custo, fornecedor, sem_comissao)
VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`, VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`,
[finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo), [finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo),
os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]); os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]);
} }
// Fase 2.1: espelho — RECEITA no workspace do laboratório (clinica_id = laboratorio_id).
// Mesmo valor/condição da DESPESA; isolado para nunca derrubar a transição de status.
try {
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
const { rows: cl } = await pool.query('SELECT nome_fantasia FROM clinicas WHERE id=$1', [os.clinica_id]);
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, pacientenome, origem, centro_custo, sem_comissao)
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${cl[0]?.nome_fantasia || ''})`.trim(), Number(os.custo),
os.paciente_id || null, os.paciente_nome || null]);
} }
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id) WHERE id=$4', [novo, garantiaAte, finId, os.id]); } catch (e) { console.error('[protese entrega→receita lab]', e.message); }
}
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($4,financeiro_lab_id) WHERE id=$5', [novo, garantiaAte, finId, finLabId, os.id]);
await pool.query( 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)`, `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]); [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]);
@@ -4445,6 +4486,40 @@ app.put('/api/gto-builder/:id/finalizar', authGuard, async (req, res) => {
await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'gto', entidadeId: b.id, acao: 'finalizou_gto', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, financeiro_id: financeiroId } }); await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'gto', entidadeId: b.id, acao: 'finalizou_gto', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, financeiro_id: financeiroId } });
schedulePush('financeiro'); schedulePush('financeiro');
} }
// Prótese (Fase 1): cada item de GTO ligado a um produto do protético abre uma OS
// de prótese (cai na Bancada do laboratório, com etapas/fotos/garantia já existentes).
// Idempotente por gto_item_id; isolado em try/catch para nunca derrubar o finalize.
try {
const { rows: protItems } = await pool.query(
'SELECT * FROM gto_items WHERE builderid = $1 AND produto_id IS NOT NULL AND protetico_id IS NOT NULL',
[req.params.id]);
const uid = req.authUser?.userId;
for (const it of protItems) {
const { rows: jaTem } = await pool.query('SELECT id FROM protese_os WHERE gto_item_id = $1 LIMIT 1', [it.id]);
if (jaTem.length) continue;
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',
[it.produto_id, it.protetico_id]);
if (!pr.length) continue;
const { rows: prot } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [it.protetico_id]);
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const labId = await labDoProtetico(it.protetico_id); // Fase 2: escopo por laboratório
await pool.query(
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, procedimento_id, tipo, produto_id, tipo_os, garantia_meses, dentes, observacoes, valor, custo, status, created_by, gto_item_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'nova',$12,$13,$14,$15,$16,'solicitado',$17,$18)`,
[osId, b.clinica_id, it.protetico_id, labId, prot[0]?.nome || null, b.pacienteid, b.paciente_nome,
b.dentistaid, it.procedimentoid || null, pr[0].nome, pr[0].id, Number(pr[0].garantia_meses) || 12,
it.dente || null, it.observacao || null, Number(it.valortotal) || 0, parseFloat(pr[0].preco || 0), uid, it.id]);
await pool.query(
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, b.clinica_id, 'OS criada a partir da GTO', uid, await actorNome(uid)]);
await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'protese_os', entidadeId: osId, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { origem: 'gto', gto_item_id: it.id, tipo: pr[0].nome } });
await notificarUsuario(it.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${pr[0].nome}) gerada por GTO. Veja na Bancada.`);
wsBroadcast(b.clinica_id, 'protese', { entidadeId: osId, acao: 'criou' });
}
} catch (e) { console.error('[GTO→protese_os]', e.message); }
res.json({ success: true, financeiro_id: financeiroId }); res.json({ success: true, financeiro_id: financeiroId });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -5282,6 +5357,9 @@ async function runMigrations() {
`CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`, `CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`,
// Fatia 1b: reserva confirmada vira recebível de locação no financeiro // Fatia 1b: reserva confirmada vira recebível de locação no financeiro
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`,
// Bloqueio de sala p/ manutenção: reserva tipo='manutencao' (valor 0, nasce confirmada,
// sem cobrança) que o pessoal da unidade pode criar para tirar a sala da agenda.
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS tipo TEXT DEFAULT 'locacao'`,
// Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica. // Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica.
// É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas). // É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas).
`CREATE TABLE IF NOT EXISTS usuario_plugins ( `CREATE TABLE IF NOT EXISTS usuario_plugins (
@@ -5630,6 +5708,30 @@ async function runMigrations() {
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`, `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`,
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`, `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`,
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS gto_item_id TEXT`, // item de GTO que originou a OS (Fase 1 prótese↔GTO)
// Prótese no Lançar GTO: o item de GTO referencia um produto do catálogo do protético.
`ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS produto_id TEXT`,
`ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS protetico_id TEXT`,
// Especialidade de prótese vinha marcada como 'odontologia' (legado) → re-tag para 'protese'
// permite o front detectar a especialidade e puxar o catálogo do protético. Idempotente.
`UPDATE especialidades SET area='protese' WHERE area IS DISTINCT FROM 'protese' AND (nome ILIKE '%protese%' OR nome ILIKE '%prótese%')`,
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
// RECEITA espelhada no lab (preenchida na entrega; idempotência) e catálogo por lab.
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`,
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
// (protese_os.laboratorio_id já existe — coluna reservada na criação da tabela.)
// Re-tag: o workspace pessoal do protético passa a ser 'laboratorio'.
`UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal' AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
// Backfill: cada OS herda o laboratorio do workspace do seu protético.
`UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
// Backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat).
`UPDATE protese_produtos p SET laboratorio_id = c.id
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)`,
`CREATE TABLE IF NOT EXISTS protese_produtos ( `CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético) protetico_id TEXT NOT NULL, -- dono (usuário protético)
@@ -6097,8 +6199,9 @@ app.get('/api/salas/:id/reservas', authGuard, async (req, res) => {
if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' }); if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' });
const isOwner = sr[0].owner_usuario_id === req.authUser.userId; const isOwner = sr[0].owner_usuario_id === req.authUser.userId;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, inicio::text, fim::text, modelo, status, valor, `SELECT id, inicio::text, fim::text, modelo, status, valor, COALESCE(tipo, 'locacao') AS tipo,
CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome,
CASE WHEN tipo = 'manutencao' THEN observacoes ELSE NULL END AS motivo
FROM sala_reservas WHERE sala_id = $1 AND status = ANY($4) AND fim > NOW() - INTERVAL '1 day' FROM sala_reservas WHERE sala_id = $1 AND status = ANY($4) AND fim > NOW() - INTERVAL '1 day'
ORDER BY inicio LIMIT 300`, ORDER BY inicio LIMIT 300`,
[req.params.id, isOwner, req.authUser.userId, RESERVA_ATIVAS]); [req.params.id, isOwner, req.authUser.userId, RESERVA_ATIVAS]);
@@ -6106,6 +6209,48 @@ app.get('/api/salas/:id/reservas', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// Bloqueio de sala para MANUTENÇÃO: tira a sala da agenda num intervalo, sem cobrança.
// Quem pode: o dono da sala OU qualquer membro com vínculo na clínica dona da sala
// (ex.: recepção). Nasce 'confirmada' (não precisa aprovação) e não gera financeiro.
app.post('/api/salas/:id/manutencao', authGuard, async (req, res) => {
try {
const { inicio, fim, motivo } = req.body || {};
if (!inicio || !fim) return res.status(400).json({ error: 'Início e fim são obrigatórios.' });
if (new Date(fim) <= new Date(inicio)) return res.status(400).json({ error: 'Fim deve ser depois do início.' });
const uid = req.authUser.userId;
const { rows: sr } = await pool.query('SELECT id, nome, owner_usuario_id, clinica_id FROM salas WHERE id = $1', [req.params.id]);
if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' });
const sala = sr[0];
const isOwner = sala.owner_usuario_id === uid;
let isDaUnidade = false;
if (!isOwner && sala.clinica_id) {
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, sala.clinica_id]);
isDaUnidade = v.length > 0;
}
if (!isOwner && !isDaUnidade) return res.status(403).json({ error: 'Sem acesso a esta sala.' });
const nome = await actorNome(uid);
const id = `res_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await withTransaction(async (client) => {
const { rows: conf } = await client.query(
`SELECT id, inicio::text, fim::text FROM sala_reservas
WHERE sala_id = $1 AND status = ANY($4) AND inicio < $3 AND fim > $2 LIMIT 1 FOR UPDATE`,
[req.params.id, inicio, fim, RESERVA_ATIVAS]);
if (conf.length) throw { code: 'CONF_SALA', conflito: conf[0] };
await client.query(
`INSERT INTO sala_reservas (id, sala_id, solicitante_id, solicitante_nome, inicio, fim, modelo, valor, status, observacoes, clinica_id, tipo)
VALUES ($1,$2,$3,$4,$5,$6,'hora',0,'confirmada',$7,$8,'manutencao')`,
[id, req.params.id, uid, nome, inicio, fim, motivo || 'Manutenção', sala.clinica_id || null]);
await registrarAudit(client, { clinicaId: sala.clinica_id || null, entidade: 'sala_reserva', entidadeId: id, acao: 'manutencao', actorId: uid, actorNome: nome, detalhes: { salaId: req.params.id, inicio, fim, motivo: motivo || null } });
});
if (!isOwner) await notificarUsuario(sala.owner_usuario_id, 'Sala bloqueada p/ manutenção',
`${nome} bloqueou a sala "${sala.nome}" para manutenção (${new Date(inicio).toLocaleString('pt-BR')}).`);
res.json({ success: true, id, status: 'confirmada', tipo: 'manutencao' });
} catch (err) {
if (err.code === 'CONF_SALA') return res.status(409).json({ error: 'CONFLITO: já existe reserva/bloqueio neste horário.', conflito: err.conflito });
res.status(500).json({ error: err.message });
}
});
// Reservar sala — pessoal OU em nome de uma unidade (clinicaId + profissional // Reservar sala — pessoal OU em nome de uma unidade (clinicaId + profissional
// designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional // designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional
// designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do // designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do
+158
View File
@@ -0,0 +1,158 @@
# Análise — Área da Recepção / Coordenação de Tratamentos (Treatment Coordinator)
> Gatilho (23/jun/2026): `recep.consulttclinic@gmail.com` lançou uma prótese mas **não tinha
> a aba para acompanhá-la**. Análise do gap + recomendação fundamentada em modelos validados
> de software odontológico. Conecta com `FASE2-PROTETICO-AREA.md` (o outro lado do fluxo).
---
## 1. Diagnóstico (DEV `scoreodonto_dev`)
| Entidade | Achado |
|---|---|
| Dono | `consulttclinic@gmail.com`*Rui Cesar*, `donoclinica` |
| Funcionária | `recep.consulttclinic@gmail.com` (`u_1780962424310_cqsxn`), role `funcionario`, vínculo na **CONSULTT CLINIC LTDA** (`c_1780871001549_koqa3o`) |
| Cargo | função "Recepcionista" (`fnc_1780962382528_m28go`) |
| Permissões (antes) | `["agenda","ortodontia","contratos","tratamentos","pacientes","lancar-gto"]` |
| Prótese lançada | OS `pos_1782228267898_zgfzn` — Prótese Total, paciente ABIGAIR, R$600, protético "Zeca Andrade Fake", status `solicitado`, **via GTO** (`gto_item_id=QOJWR6D`) |
**Gap exato:** o cargo tinha `lancar-gto` (lançar) mas **não** `proteses` (acompanhar). O RBAC da
`Sidebar.tsx` é literal (`return wsPerms.includes(item.id)`), então a OS criada por ela ficava
**invisível** para ela própria. A Fase 1 (GTO→OS) funcionou; o problema era de **configuração de
cargo**, não de arquitetura.
---
## 2. Modelos validados (referência externa)
- **Papel = "Treatment Coordinator" + Front Desk.** É a ponte entre paciente e equipe clínica:
sequencia planos multi-etapa, conduz o aceite do caso (case acceptance), arranjo financeiro e
follow-up até a conclusão — exatamente "gerir tratamentos" de ponta a ponta.
- **RBAC por papel é o padrão.** Permissões mapeadas a papéis, não a indivíduos; o front desk
recebe agenda/cadastro/cobrança, tipicamente **sem notas clínicas**. O ScoreOdonto já faz isso
via `funcoes.permissoes[]`.
- **Prótese = "Lab Case", acompanhada pelo front desk.** No Dentrix Lab Case Manager: clínico cria
a prescrição → status *Sent***o front desk muda para *Received* quando volta do lab**
*Finished* ao assentar. Ou seja: acompanhar a OS de prótese é, por modelo consagrado, **atribuição
da recepção** — o acesso que faltava.
Fontes:
- The Role of a Treatment Coordinator — https://www.dentalmanagers.com/blog/role-of-treatment-coordinator-in-dental-practice/
- What Does a Treatment Plan Coordinator Do? — https://www.teero.com/blog/treatment-plan-coordinator
- Dental Software User Permissions (Curve) — https://www.curvedental.com/blog/dental-software-user-permissions
- RBAC in Healthcare — https://www.accountablehq.com/post/role-based-access-control-rbac-in-healthcare-benefits-examples-and-best-practices
- Manage Your Lab Cases in Dentrix — https://magazine.dentrix.com/manage-your-lab-cases-in-dentrix/
- Open Dental — Lab Cases — https://www.opendental.com/manual/labcasemanage.html
---
## 3. Ação aplicada — Nível 1 (configuração, sem código)
**Aplicado no DEV em 23/jun/2026.** Função "Recepcionista" da CONSULTT CLINIC LTDA atualizada:
```sql
UPDATE funcoes
SET permissoes = '["agenda","pacientes","tratamentos","ortodontia","proteses","lancar-gto","orcamentos","contratos"]'
WHERE id = 'fnc_1780962382528_m28go'; -- UPDATE 1
```
Acréscimos: **`proteses`** (acompanhar a OS que ela lança) e **`orcamentos`** (case acceptance —
coração do papel TC). Efeito imediato: a OS `pos_1782228267898_zgfzn` passa a aparecer para ela.
> Escopo: alteração **só no DEV**. Não há mudança de código nem migração — é dado de configuração.
> Para refletir em outras clínicas, ajustar o cargo equivalente de cada uma (ou rever o template
> de cargos padrão, se existir).
---
## 3b. Causa raiz (investigação 23/jun) — `proteses` não é concedível pela UI
A correção de dado acima trata o sintoma. A **causa raiz** é um bug de configuração de RBAC:
- Cargos **não** têm seed no backend (signup não cria funções). São criados manualmente pelo
dono em Gestão de Equipe (`POST → INSERT INTO funcoes`, `server.js:806`), partindo de 4
templates do frontend (`GestaoEquipeView.tsx:96`).
- **Nenhum** template inclui `proteses`: RECEPÇÃO (`dashboard,agenda,pacientes,leads,orcamentos`),
AUXILIAR (`...,ortodontia,tratamentos`), FINANCEIRO (`...,financeiro,relatorios,lancar-gto`),
GERENTE (`[...TODAS_PAGINAS]`).
- **Raiz:** `proteses` **não está em `PAGINAS_RBAC`** (`GestaoEquipeView.tsx:57`), a lista mestre
de páginas atribuíveis. Como `TODAS_PAGINAS = PAGINAS_RBAC.flatMap(...)`, **nem o GERENTE /
"MARCAR TODAS"** concede prótese. ⇒ **É impossível um dono dar `proteses` a um funcionário
pela interface** — só via UPDATE direto no banco (o que foi feito na §3).
- Também ausentes da lista atribuível, embora em uso: **`contratos`** (a função tinha — legado),
`comissoes`, `glosas`.
**Resposta à pergunta "o cargo Recepcionista padrão nasce sem proteses?":** Sim — e mais,
*nenhum* cargo consegue tê-la pela UI hoje.
### Fix de raiz (código) — ✅ aplicado e validado no DEV (23/jun)
Em `frontend/views/GestaoEquipeView.tsx`:
- `PAGINAS_RBAC`, grupo `ATENDIMENTO`: + `{ key: 'proteses', label: 'PRÓTESES' }` e
`{ key: 'contratos', label: 'CONTRATOS' }` → agora concedíveis pela UI (e entram em
`TODAS_PAGINAS`/GERENTE/"MARCAR TODAS").
- Templates: RECEPÇÃO ganhou `contratos, lancar-gto, proteses`; AUXILIAR ganhou `proteses`
(alinhado ao papel TC).
**Validação:** `vite build` transformou 2382 módulos OK; container DEV rebuildado e no ar; o
bundle servido pelo nginx DEV (`index-*.js`, alvo de `dev.scoreodonto.com`) contém
`key:"proteses",label:"PRÓTESES"` e a string `proteses` nos 3 pontos editados. Cargos padrão
passam a nascer coerentes — sem UPDATE manual por clínica.
> ⚠️ Pendente de commit + deploy à produção (a mudança está só no DEV). Os fixes de **dado**
> (§3, função da CONSULTT CLINIC) continuam sendo só-DEV e não se propagam por este fix de código.
## 4. Recomendação — Nível 2 (estrutural, "Fase 3")
Em vez de abas soltas (`tratamentos`, `proteses`, `ortodontia`), um **hub de Coordenação de
Tratamentos por paciente**: pipeline único com o status de cada frente (clínico / prótese-lab /
orto), espelhando o "lab case visível para toda a equipe" do Dentrix. View nova que **agrega o que
já existe** (`tratamentos` + `protese_os` + ortodontia por paciente) — sem dado novo.
## 5. Conexão com a Fase 2
Dois lados do mesmo fluxo, unidos pela mesma `protese_os`:
- **Fase 2** → casa do **protético** (quem produz; avança status na Bancada).
- **Esta análise** → **recepção/TC** (quem encomenda e acompanha; lê o status no hub).
O status que o lab avança é o mesmo que a recepção lê — o "Lab Case Manager" repartido entre os papéis.
---
## 6. Salas: acesso do funcionário corrigido + bloqueio de manutenção (23/jun)
Gatilho: a mesma recepcionista aparecia com **MINHAS SALAS** e **ALUGAR SALAS** (gestão de
locação, de titular) e deveria ter apenas a **agenda da sala** e o **bloqueio para manutenção**.
### 6a. Bug — vazamento dos menus de locação ✅ corrigido (`Sidebar.tsx`)
`MINHAS SALAS` (locador) e `ALUGAR SALAS` (locatário) eram empurrados a "todos menos
superadmin" (`Sidebar.tsx:42`), vazando para **funcionário e paciente** — papéis que não são
titulares de sala. Passaram a exibir apenas a `PAPEIS_LOCACAO`
(`donosala/donoclinica/donoconsultorio/admin/dentista/biomedico/protetico`). O backend já era
seguro por ownership; era vazamento só de UI.
### 6b. Feature — acesso operacional + manutenção ✅ implementada e validada no DEV
Não existia caminho para "funcionário vê só a agenda" nem "bloqueio para manutenção" — foram
criados (modelos de mercado: *Dentrix/Open Dental Lab... não; aqui é gestão de espaço* — o
padrão é **front desk opera a agenda da sala sem tocar no financeiro do locador**).
- **Acesso operacional** (`server.js` `listarWorkspaces`): sala cuja `clinica_id` é uma clínica
onde o usuário tem vínculo (e ele **não** é o dono) entra como workspace `tipo='sala'` com
`papel_sala='operador'`. O `SalaHomeView` detecta o modo operador e **esconde recebíveis,
relatório e baixa** — mostra só a **agenda** + botão de bloqueio.
- **Bloqueio de manutenção** (`POST /api/salas/:id/manutencao`): reserva `tipo='manutencao'`
(coluna nova, default `'locacao'`), `valor=0`, nasce `'confirmada'` (sem aprovação) e **não
gera recebível**. Acesso: dono **ou** membro com vínculo na clínica da sala. Reaproveita o
anti-conflito de horário (409) e o cancelamento por solicitante/unidade. A agenda renderiza
o bloqueio em **cinza** (legenda "Manutenção").
**Validação ponta a ponta (DEV, JWT real da recepcionista, sala de teste vinculada à clínica):**
| Caso | Resultado |
|---|---|
| Sala da clínica vira workspace operador dela | ✅ query retorna a sala |
| POST bloqueio | ✅ `status:confirmada, tipo:manutencao` |
| GET agenda mostra bloqueio + motivo | ✅ `valor:0, motivo:"Conserto do equipo"` |
| Conflito de horário | ✅ HTTP 409 |
| Não gera financeiro | ✅ 0 lançamentos |
Commits: `fix(salas)` (menu) + `feat(salas)` (operador + manutenção). Backend e frontend no ar
no DEV; dados de teste limpos.
> ⚠️ Pendente de push + deploy (produção segue em v1.0.19).
+217
View File
@@ -0,0 +1,217 @@
# Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1
> ✅ **IMPLEMENTADO e validado em DEV (23/jun/2026).** Commits `feat(protese): Fase 2 backend`
> + `frontend`. Validações: registro de protético nasce `laboratorio`; backfill preenche
> `laboratorio_id`; bancada roteia por lab (fallback por pessoa); entrega gera **par espelhado**
> DESPESA(clínica)+RECEITA(lab) R$600, idempotente (409 na re-entrega); menu do lab no bundle.
>
> ⚠️ **Bug pré-existente achado e corrigido junto:** o INSERT da DESPESA de prótese (Fase 1, já
> em produção v1.0.20) usava `paciente_nome` — coluna inexistente em `financeiro` (é
> `pacientenome`). A entrega de OS com `custo>0` **abortava** em produção. Corrigido no mesmo
> commit de backend. **Recomenda-se priorizar o deploy** por causa disso.
> Companheiro de `FASE2-PROTETICO-AREA.md` (o modelo). Aqui está o **como**, a nível de
> arquivo/função, fiel ao código atual de `backend/server.js` e `frontend/`.
> Decisões aplicadas: **1 lab por protético**, **só terceirizado**, **receita espelhada na 2.1**.
> Disciplina herdada da Fase 1: migrações aditivas/idempotentes; blocos novos em `try/catch`
> que nunca derrubam o fluxo principal; `protetico_id` sempre preservado (fallback).
---
# PASSO 2.0 — Dados invisíveis (backfill `laboratorio_id`)
Nenhuma mudança de UI. Objetivo: todo protético tem um workspace `tipo='laboratorio'` e toda
OS/produto carrega `laboratorio_id`. Validar 100% em DEV (`pgdev`) antes de seguir.
## 2.0.a — Migrações (em `runMigrations()`, junto às da Fase 1, ~linha 5663)
```js
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, // RECEITA espelhada (2.1)
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
// laboratorio_id em protese_os JÁ EXISTE (coluna reservada na criação da tabela) — não recriar.
// re-tag: o workspace pessoal do protético passa a ser 'laboratorio'
`UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
// backfill: cada OS herda o laboratorio do workspace do seu protético
`UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
// backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
`UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
// índice por lab (o de protetico_id já existe; adiciona o do lab)
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
```
> O `UPDATE especialidades … area='protese'` da Fase 1 já está logo acima — não duplicar.
## 2.0.b — Helper: resolver o laboratório de um protético
Novo helper perto de `proteseOsAccesso` (~linha 3772). 1 lab por protético ⇒ `LIMIT 1`.
```js
// O workspace 'laboratorio' do protético (1 por protético nesta fase).
async function labDoProtetico(proteticoId) {
if (!proteticoId) return null;
const { rows } = await pool.query(
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' AND COALESCE(deleted_at IS NULL, true) LIMIT 1",
[proteticoId]);
return rows[0]?.id || null;
}
```
> Se `clinicas` não tiver `deleted_at`, usar só `WHERE owner_id=$1 AND tipo='laboratorio'`.
## 2.0.c — Gravar `laboratorio_id` na CRIAÇÃO da OS (2 pontos)
**(i) `POST /api/protese/os`** (~linha 3893): resolver o lab e incluí-lo no INSERT.
```js
const labId = await labDoProtetico(b.protetico_id); // antes do INSERT
// no INSERT: acrescentar a coluna laboratorio_id e o valor labId
// ... protetico_id, laboratorio_id, protetico_nome, ...
// VALUES ($1,$2,$3,$LAB,$4,...)
```
**(ii) Bloco GTO→protese_os (Fase 1)** em `PUT /api/gto-builder/:id/finalizar`
(o INSERT em `protese_os` que você acabou de escrever): mesma adição.
```js
const labId = await labDoProtetico(it.protetico_id); // dentro do for, antes do INSERT
// incluir laboratorio_id = labId no INSERT da OS gerada por GTO
```
> Assim, OS novas já nascem com `laboratorio_id`; as antigas foram cobertas pelo backfill.
## 2.0.d — Bancada roteia por laboratório (com fallback por pessoa)
`GET /api/protese/bancada` (~linha 3926). Hoje: `WHERE protetico_id=$1`. Passa a aceitar OS
do(s) laboratório(s) do usuário, mantendo fallback para OS ainda sem `laboratorio_id`:
```js
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid); // o lab do protético logado
const vals = [uid, labId];
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}`;
}
// resto idêntico (ORDER BY / LIMIT / map)
```
> `proteseOsAccesso` **não muda na 2.0** (1 lab por protético ⇒ `protetico_id===uid` ainda
> identifica o lab). O `OR por vínculo no lab` entra só na 2.3 (equipe/`tecnico_protese`).
## 2.0.e — Novos protéticos já nascem com `tipo='laboratorio'`
No `POST /api/register` (~linha 625), o INSERT do workspace usa `tipo='pessoal'` para todos.
Diferenciar o protético:
```js
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,$4)` // $3 = wsTipo
```
> O seed de catálogo (`seedProteseProdutos(userId)`) continua por `protetico_id` (owner) —
> sem mudança. (O `laboratorio_id` do catálogo é resolvido pelo backfill / por leitura.)
## ✅ Critérios de aceite 2.0 (validar em DEV)
- `SELECT count(*) FROM protese_os WHERE laboratorio_id IS NULL`**0** (após backfill).
- Toda `clinicas` de protético tem `tipo='laboratorio'`.
- Criar OS nova (via Nova OS **e** via finalizar GTO) → `laboratorio_id` preenchido.
- Bancada continua mostrando exatamente as mesmas OS de antes (fallback cobre o legado).
- **Zero** mudança visível na UI.
---
# PASSO 2.1 — Casa própria + receita espelhada
Agora o protético entra numa **área de Laboratório** (menu próprio) e a entrega passa a
lançar a receita no workspace do lab.
## 2.1.a — BACKEND: receita espelhada na entrega
Em `POST /api/protese/os/:id/status`, dentro do `if (novo === 'entregue') { ... }`, logo após
o bloco que cria a **DESPESA** (`finId`). Mesma condição (`custo>0 && tipo_os≠'garantia'`):
```js
// Espelho: RECEITA no workspace do laboratório (clinica_id = laboratorio_id). Idempotente.
let finLabId = os.financeiro_lab_id || null;
try {
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, paciente_nome, origem, centro_custo, sem_comissao)
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${clinicaNome || ''})`.trim(), Number(os.custo),
os.paciente_id || null, os.paciente_nome || null]);
}
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }
```
E no `UPDATE protese_os` da entrega, persistir o id:
```js
// ...financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($N,financeiro_lab_id)...
```
> `clinicaNome` = nome da clínica de origem (1 query opcional, ou usar `os.clinica_id`).
> **Estorno** (cancelar entrega) é tarefa à parte: reverter os dois `financeiro` — fica para
> a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 se `entregue`).
## 2.1.b — FRONTEND: menu da área de Laboratório (`Sidebar.tsx`)
**(i)** Acrescentar os itens ao array `menuItems` (~linha 53):
```js
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
```
(catálogo/clientes/agenda/financeiro entram na 2.2/2.3 — não criar itens mortos agora.)
**(ii)** Isolar o menu quando o workspace ativo é laboratório — **espelhar** o bloco que já
existe para `tipo==='sala'` (~linha 98). Inserir antes dele:
```js
// Contexto de LABORATÓRIO: área própria do protético (não é clínica).
if ((activeWorkspace as any)?.tipo === 'laboratorio')
return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
```
> Como o protético tem **1** workspace e ele é `tipo='laboratorio'`, esse branch governa o
> menu dele. O antigo filtro `currentRole==='protetico'` (linha ~131) deixa de ser alcançado
> para o caso comum, mas **mantê-lo** como rede de segurança (protético sem workspace lab).
## 2.1.c — FRONTEND: roteamento das telas novas (`App.tsx`)
No switch/router de `view` do `App.tsx`, mapear:
```jsx
case 'lab-bancada': return <ProteseView />; // já renderiza modo 'bancada' p/ role protetico
case 'lab-home': return <LabHomeView />; // novo painel (abaixo)
```
> `ProteseView` já decide `modo='bancada'` por `getCurrentRole()==='protetico'` — reaproveita
> 100%. Só muda o **id de menu** que a abre.
## 2.1.d — FRONTEND: `LabHomeView` (painel mínimo)
Nova view `frontend/views/LabHomeView.tsx`. Cards a partir de dados que já existem:
- **Fila por status** e **atrasadas**: derivar de `getProteseBancada()` (já retorna `atrasada`).
- **Faturamento do mês**: somar `financeiro` RECEITA `origem='protese'` do workspace lab
(rota de financeiro existente, escopada por `clinicaId` = id do lab).
- **Prazo médio / entregues no mês**: agregação simples sobre a bancada.
Sem rota nova obrigatória na 2.1 (reusa `bancada` + `financeiro`); se quiser um resumo de 1
chamada, criar `GET /api/protese/lab/resumo` depois.
## ✅ Critérios de aceite 2.1
- Logado como protético, a Sidebar mostra **PAINEL DO LAB** + **BANCADA** (+ notificações/config).
- A clínica continua com a tela PRÓTESES (modo `clinica`) **intacta**.
- Entregar uma OS com `custo>0` (não-garantia) cria **2** lançamentos: DESPESA na clínica e
RECEITA no lab (mesmo valor `custo`); reentregar/reprocessar **não** duplica (`COALESCE`).
- OS de garantia entregue → **nenhum** lançamento (nos dois lados).
- `LabHomeView` mostra fila + faturamento do mês do laboratório.
---
# Ordem de execução e deploy
1. Branch a partir da `main` (que já tem a Fase 1 não-commitada — **commitar a Fase 1 antes**).
2. 2.0 (backend só) → subir em DEV (`dev.scoreodonto.com`/`pgdev`), rodar os SELECTs de aceite.
3. 2.1 (backend receita + front) → validar aceite em DEV.
4. Commit por sub-fase; push → CI builda por commit; `git tag vX.Y.Z` promove à produção.
> Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).
+220
View File
@@ -0,0 +1,220 @@
# Fase 2 — Área do Protético (Laboratório como workspace de 1ª classe)
> Desenho de modelo. Continuação da **Fase 1 (GTO → Prótese)**, onde finalizar uma GTO
> abre automaticamente uma OS na Bancada (`protese_os.gto_item_id`).
> **Objetivo da Fase 2:** o protético deixa de ser "um papel que só vê a Bancada" e passa
> a ter uma **área de gestão própria** — o Laboratório — equivalente ao que a Clínica é para
> o dentista. Ele gere catálogo, equipe, clientes (clínicas), financeiro e agenda de entregas.
---
## 1. Onde estamos hoje (não reinventar)
O alicerce já existe — a Fase 2 **promove**, não cria do zero:
| Já existe | Onde |
|---|---|
| Role `protetico` (válido em `usuarios` e `vinculos`) | `server.js` VALID_ROLES / constraints |
| Workspace pessoal "LABORATÓRIO DE …" criado no signup | `clinicas (tipo='pessoal')` + `vinculos (role='protetico')` |
| Catálogo de produtos (preço fixo + garantia) | `protese_produtos (protetico_id)` |
| Bancada (fila de OS do protético) | `GET /api/protese/bancada` (roteia por `protetico_id`) |
| OS com eventos, fotos, status, garantia, financeiro | `protese_os`, `protese_os_evento`, `protese_os_foto` |
| `ProteseView` com `modo: 'bancada' | 'clinica'` por role | `frontend/views/ProteseView.tsx` |
| **`protese_os.laboratorio_id`** — reservado, hoje `null` | comentário: "workspace destino (futuro)" |
| Menu filtrado por role (protético = subset) | `Sidebar.tsx` linha ~131 |
**Lacuna que a Fase 2 fecha:** o Laboratório não é uma *entidade gerenciável*. O protético
roteia OS por `protetico_id` (a pessoa), não por um laboratório (a empresa). Não há painel,
equipe, visão de clientes, nem financeiro do lado do laboratório. A OS conhece a *pessoa*,
não a *bancada/empresa*.
---
## 2. Decisão de modelo: Laboratório = workspace `tipo='laboratorio'`
O sistema já trata **workspace** de forma polimórfica: `clinicas` é a tabela-base e `tipo`
distingue (`clinica` | `pessoal` | `sala`). A Fase 2 introduz **`tipo='laboratorio'`** em vez
de criar uma tabela nova. Isso reaproveita: vínculos, troca de workspace, RBAC por cargo,
financeiro, notificações e o seletor de workspace da Sidebar.
```
usuarios (role='protetico')
│ vinculos (role='protetico' | 'tecnico_protese')
clinicas (tipo='laboratorio') ◄── "LABORATÓRIO DE X" deixa de ser 'pessoal'
┌───────────┼─────────────────────────────┐
▼ ▼ ▼
protese_produtos protese_os (laboratorio_id) equipe (vinculos do lab)
(catálogo) ▲ roteamento por LAB,
│ não mais só por pessoa
clínicas-cliente enviam OS ──┘
```
### Migração do dado existente (aditiva, idempotente)
```sql
-- 1) re-tag do workspace pessoal do protético → laboratorio
UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico');
-- 2) backfill: toda OS legada ganha o laboratorio_id do workspace do seu protetico
UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL
AND c.tipo='laboratorio'
AND c.owner_id = o.protetico_id;
-- 3) o catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT;
UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id;
```
> `protetico_id` **continua** populado em todas as tabelas (compatibilidade da Fase 1 e do
> roteamento por pessoa). `laboratorio_id` passa a ser a **chave de escopo** da área.
---
## 3. A Área do Laboratório (navegação)
Quando o `activeWorkspace.tipo === 'laboratorio'`, a Sidebar troca para um menu próprio
(análogo ao `tipo==='sala'` que já isola o menu). Itens:
| Item | Conteúdo | Reaproveita |
|---|---|---|
| `lab-home` | **Painel** do laboratório: OS por status, atrasadas, faturamento do mês, prazo médio | novo dashboard + agregações de `protese_os` |
| `lab-bancada` | **Bancada** (fila de produção, kanban por status) | `ProteseView` modo bancada (já existe) |
| `lab-catalogo` | **Catálogo** de produtos (preço/garantia) — vira tela, não modal | `ProdutosModal` → view |
| `lab-clientes` | **Clínicas-cliente**: quem envia OS, volume, ticket, pendências | distinct de `protese_os.clinica_id` |
| `lab-agenda` | **Agenda de entregas** por `prazo_entrega` | AgendaView adaptada / nova |
| `lab-financeiro` | **A receber** das clínicas (a OS gera DESPESA na clínica = RECEITA no lab) | `protese_os.financeiro_id` (elo) |
| `lab-equipe` | **Técnicos** do laboratório (RBAC por cargo já existe) | `vinculos` + permissões de workspace |
| `notificacoes` / `configuracoes` | base | já existe |
A tela "PRÓTESES" da **clínica** (modo `clinica`) não muda — ela continua **enviando** OS.
O que muda é o **lado de quem recebe**, que ganha uma casa própria.
### Roteamento de OS: pessoa → laboratório
- Hoje: `bancada` filtra `WHERE protetico_id = me`.
- Fase 2: `bancada` filtra `WHERE laboratorio_id = activeWorkspace.id` (com fallback
`OR (laboratorio_id IS NULL AND protetico_id = me)` durante a transição).
- Ganho imediato: **mais de um técnico** pode atender a mesma bancada (equipe), e o protético
dono pode ter **mais de um laboratório**.
---
## 4. Modelo de dados — deltas da Fase 2
Tudo aditivo (migrações no padrão `ALTER TABLE … IF NOT EXISTS`, como a Fase 1):
```sql
-- workspace
clinicas.tipo -- novo valor possível: 'laboratorio'
-- escopo por laboratório (a empresa), além de protetico_id (a pessoa)
protese_os ADD laboratorio_id TEXT -- (já reservado; passa a ser populado/backfill)
protese_os ADD financeiro_lab_id TEXT -- RECEITA espelhada no lab (§9; idempotência)
protese_produtos ADD laboratorio_id TEXT
-- equipe do laboratório: novo role de vínculo (técnico não-dono)
vinculos.role -- incluir 'tecnico_protese' no CHECK constraint
-- relação clínica↔laboratório (opcional, p/ "meus laboratórios favoritos"/preços negociados)
CREATE TABLE IF NOT EXISTS lab_clientes (
id TEXT PRIMARY KEY,
laboratorio_id TEXT NOT NULL,
clinica_id TEXT NOT NULL,
apelido TEXT,
desconto_pct NUMERIC DEFAULT 0, -- preço negociado (futuro)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (laboratorio_id, clinica_id)
);
```
Índices: `idx_protese_os_lab` passa a ser `(laboratorio_id, status)`.
---
## 5. Permissões (RBAC)
Reaproveita o RBAC por cargo que já existe na Sidebar (`wsRole`/`wsPerms`):
| Papel no laboratório | Pode |
|---|---|
| `protetico` (dono do lab) | tudo: catálogo, equipe, financeiro, status de qualquer OS |
| `tecnico_protese` (membro) | bancada + avançar status + fotos; **sem** financeiro/equipe/catálogo |
| clínica-cliente (`isClinic` em `proteseOsAccesso`) | criar OS, ver suas OS, entregar/cancelar, **sem** ver custo/bancada de outros |
`proteseOsAccesso(os, userId)` ganha o caso "membro do laboratório dono da OS"
(`vinculo em laboratorio_id`), além do dono atual.
---
## 6. Plano de implementação incremental
Cada passo é deployável e reversível (migrações aditivas → rollback de código é seguro).
- **2.0 — Dados (invisível):** migrações + backfill `laboratorio_id`; `bancada` lê por lab com
fallback por pessoa. Nada muda na UI. *Risco mínimo, valida o backfill em DEV (`pgdev`).*
- **2.1 — Casa própria + receita espelhada:** `tipo='laboratorio'` re-tagueado; Sidebar com
menu do laboratório; `lab-home` (painel) + `lab-bancada`; **espelho de RECEITA na entrega**
(§9) — o financeiro mínimo do lab já nesta fase, conforme decisão 3.
- **2.2 — Catálogo e clientes:** `ProdutosModal` vira `lab-catalogo`; tela `lab-clientes`.
- **2.3 — Financeiro completo e equipe:** `lab-financeiro` (extrato/a-receber por cliente,
sobre as receitas já lançadas na 2.1) + `tecnico_protese` (convite de membro reusando o
fluxo de equipe existente).
- **2.4 — Agenda de entregas + multi-laboratório** (expansão; modelo já suporta, sem migração).
---
## 7. O que **não** muda (contrato preservado)
- O lado **clínica** de `ProteseView` (modo `clinica`) e o envio de OS.
- A **Fase 1**: GTO finalizada continua abrindo OS (`gto_item_id`), agora já com
`laboratorio_id` preenchido na criação.
- `protetico_id` permanece em todas as tabelas — roteamento por pessoa segue funcionando
como fallback; nenhuma OS legada fica órfã.
---
## 8. Decisões de produto (fechadas em 23/jun/2026)
1. **Um laboratório por protético.** A UI assume 1 lab. O modelo de dados já suporta N
(`laboratorio_id` por OS/produto), então multi-lab é expansão futura **sem migração**.
2. **Laboratório terceirizado** (empresa separada que cobra da clínica). A flag `interno`
de `getProteseLaboratorios` fica como gancho futuro, mas **não** é tratada agora —
todo lab gera receita/despesa cruzada entre tenants.
3. **Financeiro espelhado já na 2.1.** A OS entregue, que hoje lança **DESPESA na clínica**
(`protese_os.financeiro_id`), passa a lançar simultaneamente uma **RECEITA no workspace
do laboratório**. Ver §9.
---
## 9. Financeiro espelhado (decisão 3) — DESPESA na clínica ↔ RECEITA no lab
Hoje a entrega da OS gera um lançamento de **DESPESA** na clínica de origem
(`protese_os.financeiro_id`), no valor de **`os.custo`** (o preço de catálogo do lab — o que a
clínica paga ao laboratório; `os.valor` é o preço ao paciente, markup da clínica, e **não**
entra aqui). Como o laboratório agora é um workspace de 1ª classe, o mesmo evento de entrega
lança um **par espelhado**, ambos sobre `os.custo`:
```
entrega da OS (status='entregue', custo>0, tipo_os≠'garantia')
├─► financeiro (DESPESA) clinica_id = os.clinica_id valor = os.custo [já existe]
└─► financeiro (RECEITA) clinica_id = os.laboratorio_id valor = os.custo [novo]
```
- Mesma **condição** da despesa já existente: só lança se `custo > 0` e `tipo_os ≠ 'garantia'`
(OS de garantia refaz sem cobrar). Sai no mesmo `if (novo === 'entregue')`.
- Idempotente, no padrão da Fase 1: novo `protese_os.financeiro_lab_id` guarda o id do
lançamento de receita; se já existe (`COALESCE`), não duplica.
- Isolado em `try/catch` para **nunca** derrubar a transição de status (mesma disciplina do
bloco GTO→protese_os da Fase 1).
- Estorno: cancelar/estornar a entrega deve reverter **os dois** lançamentos.
Por isso o **passo 2.1 já inclui o financeiro mínimo** (apenas o espelho de receita na
entrega); o item de menu `lab-financeiro` completo (extrato, a-receber por cliente) continua
na 2.3.
> ⚠️ Pré-requisito: `laboratorio_id` precisa estar populado **antes** de ligar o espelho
> (senão a receita nasce sem destino). Por isso a ordem **2.0 (backfill) → 2.1** é obrigatória.
+4 -4
View File
@@ -1,6 +1,6 @@
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto # Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
> Resposta direta às 6 perguntas essenciais, **verificada em 17/jun/2026**. Somente fatos do estado atual. > Resposta direta às 6 perguntas essenciais, **verificada em 23/jun/2026**. Somente fatos do estado atual.
> **Documento principal: em caso de divergência entre docs, este prevalece.** > **Documento principal: em caso de divergência entre docs, este prevalece.**
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo). > Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
@@ -9,7 +9,7 @@
### 1. Onde está o código? ### 1. Onde está o código?
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`. - **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/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.16 · commit `471c2c2`**. - **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.20 · commit `8676308`**.
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única). - **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
### 2. Onde está o banco? ### 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). -**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? ### 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.16 · `471c2c2` · 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.20 · `8676308` · 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. - 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. -**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
@@ -39,4 +39,4 @@
--- ---
## Veredito de uma linha ## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.16**), 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.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.
+28 -15
View File
@@ -1,8 +1,8 @@
# Status — Segurança & Produção (ScoreOdonto) # Status — Segurança & Produção (ScoreOdonto)
> Resumo do que foi feito e do que falta. Atualizado em **19/jun/2026**. > Resumo do que foi feito e do que falta. Atualizado em **22/jun/2026**.
> Produção no ar: **v1.0.18** (`fc00622`). **Todos os commits de código estão LOCAIS (sem `git push`)** — > Produção no ar: **v1.0.19** (`715b2dc`) — fila **A** concluída (push → CI → tag → deploy). **Commits
> exceto a rotação do `JWT_SECRET`, que **já foi aplicada e verificada em produção**. > publicados (`git push` feito).** A rotação do `JWT_SECRET` segue aplicada e verificada em produção.
--- ---
@@ -48,20 +48,28 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
### 5. Pré-requisito de produção ### 5. Pré-requisito de produção
- **`SCOREO_DB_PASS` provisionado na VPS1** (`/home/deploy/stack/scoreodonto.com/.env`, 0600), valores - **`SCOREO_DB_PASS` provisionado na VPS1** (`/home/deploy/stack/scoreodonto.com/.env`, 0600), valores
extraídos do container em execução, `.gitignore` da VPS1 ajustado, conexão validada (`SELECT 1` = ok). extraídos do container em execução, `.gitignore` da VPS1 ajustado, conexão validada (`SELECT 1` = ok).
**Produção não foi tocada.**
### 6. Fila A concluída — **PRODUÇÃO EM `v1.0.19`** (22/jun/2026)
- **`git push` do lote** (RBAC 99 rotas + segredos fora do compose/imagem + correções de frontend).
- **CI desbloqueada:** o build falhou na 1ª vez (`SCOREO_DB_PASS` com `:?` quebrava a interpolação do
compose no `docker compose build` do runner) → corrigido com placeholder runtime-only no `build.yml`
(commit `715b2dc`). Build verde → imagens `:715b2dc` (back+front, atômicas) publicadas no registry.
- **Tag `v1.0.19``715b2dc`:** job `promote` re-tagueou `:715b2dc``:v1.0.19` **sem rebuild**
(digests idênticos conferidos) e rodou `deploy-prod.sh v1.0.19` na VPS1.
- **Backup do banco antes do deploy:** `~/backups/scoreodonto_20260622_0005.dump` na VPS1 (PG18).
- **Verificado em produção:** `/api/version` = `v1.0.19` (`715b2dc`, PROD); `/api/health` = ok
(DB ok, cache ok). DEV (8020) realinhado ao mesmo commit via `./dev-build.sh` (mostra `v1.0.19`).
- **Rollback trivial:** `./deploy-prod.sh v1.0.18` (+ dump do banco, se necessário).
--- ---
## ⏳ O QUE FALTA ## ⏳ O QUE FALTA
### A) Levar o trabalho a produção (fila) ### A) Levar o trabalho a produção (fila) — ✅ CONCLUÍDA
1. **Imagem limpa `v1.0.19`** (remove segredos embutidos na imagem): 1. ~~**Imagem limpa `v1.0.19`**~~**FEITO** (ver "Fila A concluída" acima). Produção em `v1.0.19`/`715b2dc`.
`git push` → CI builda `:<sha>` com `.dockerignore``git tag v1.0.19``deploy-prod.sh v1.0.19`
validar `/api/version` + `/api/health`. **Rollback trivial:** `deploy-prod.sh v1.0.18`.
- Leva junto: RBAC (99 rotas), correções de frontend, etc.
2. **Compose novo na VPS1 (opcional, passo 2b):** `deploy-prod.sh` **não faz `git pull`** → o 2. **Compose novo na VPS1 (opcional, passo 2b):** `deploy-prod.sh` **não faz `git pull`** → o
`docker-compose.yml` da VPS1 segue o antigo. Para tirar a senha hardcoded do compose local, copiar o `docker-compose.yml` da VPS1 segue o antigo. Para tirar a senha hardcoded do compose local, copiar o
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso. novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso. **Ainda pendente.**
### B) Rotação dos demais segredos (ainda vivos no histórico/artefatos antigos) ### B) Rotação dos demais segredos (ainda vivos no histórico/artefatos antigos)
3. **Senha do banco** (`scoreodonto_user`) — contida ao scoreodonto; exige refactor já feito no compose + 3. **Senha do banco** (`scoreodonto_user`) — contida ao scoreodonto; exige refactor já feito no compose +
@@ -88,11 +96,12 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
--- ---
## Commits locais da sessão (nenhum `push`, exceto JWT já aplicado em prod) ## Commits da sessão — **PUBLICADOS** (`git push` feito; produção em `v1.0.19`)
> **13 commits desta sessão** (incluindo este documento) ainda **locais**. O repositório está > Todos empurrados para `origin/main`. Promovidos a produção via tag `v1.0.19` (commit `715b2dc`).
> `ahead 14` de `origin/main` — o 14º (`7c5b199 docs(LEIA)`) é anterior a esta sessão.
> A rotação do `JWT_SECRET` **não** está nesta lista (foi ação operacional na VPS, sem commit). > A rotação do `JWT_SECRET` **não** está nesta lista (foi ação operacional na VPS, sem commit).
``` ```
715b2dc ci(build): desbloqueia build — SCOREO_DB_PASS placeholder na interpolação
8a27f3b docs+infra: corrige status (JWT/contagem) + versão em DEV (dev-build.sh)
6adf353 docs: status geral de segurança & produção (este documento) 6adf353 docs: status geral de segurança & produção (este documento)
f22e30e docs(skills): completa as 62 skills f22e30e docs(skills): completa as 62 skills
e83af04 docs(skills): biblioteca operacional e83af04 docs(skills): biblioteca operacional
@@ -110,7 +119,11 @@ fcf2fb1 security(infra): .env fora do git + .dockerignore
``` ```
## Como retomar ## Como retomar
- **Para publicar:** seguir a fila **A** (push → `v1.0.19` → deploy → validar health/version). - **Fila A:** ✅ concluída — produção em **`v1.0.19`** (`715b2dc`). Próximo release: `git push` → CI
builda `:<sha>``git tag vX.Y.Z` → job `promote` deploya na VPS1.
- **Antes de abrir a usuários reais:** concluir **B** (rotações) — sobretudo as API keys (você) e a senha do banco. - **Antes de abrir a usuários reais:** concluir **B** (rotações) — sobretudo as API keys (você) e a senha do banco.
- Tudo de código testado em **DEV** (`dev.scoreodonto.com`, banco `pgdev`); produção segue **v1.0.18** intacta. - **Pendência pontual:** passo **A.2** (compose novo na VPS1 via scp + recreate) para tirar a senha
hardcoded do compose local.
- Código rodando em **DEV** (`dev.scoreodonto.com`) e **PROD** no mesmo commit `715b2dc`; rollback de
prod: `./deploy-prod.sh v1.0.18` (+ dump `~/backups/scoreodonto_20260622_0005.dump` se necessário).
</content> </content>
+14 -1
View File
@@ -31,6 +31,7 @@ import { ReportsView } from './views/ReportsView.tsx';
import { ComissoesView } from './views/ComissoesView.tsx'; import { ComissoesView } from './views/ComissoesView.tsx';
import { GlosasView } from './views/GlosasView.tsx'; import { GlosasView } from './views/GlosasView.tsx';
import { SalaHomeView } from './views/SalaHomeView.tsx'; import { SalaHomeView } from './views/SalaHomeView.tsx';
import { LabHomeView } from './views/LabHomeView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx'; import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { OrcamentosView } from './views/OrcamentosView.tsx'; import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx'; import { ContratosView } from './views/ContratosView.tsx';
@@ -84,6 +85,8 @@ export type ViewKey =
| 'minhas-salas' | 'minhas-salas'
| 'alugar-salas' | 'alugar-salas'
| 'sala-home' | 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'marketplace-profissionais' | 'marketplace-profissionais'
| 'criar-clinica' | 'criar-clinica'
| 'rx-config' | 'rx-config'
@@ -134,6 +137,8 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/minhas-salas': 'minhas-salas', '/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas', '/alugar-salas': 'alugar-salas',
'/sala-home': 'sala-home', '/sala-home': 'sala-home',
'/lab-home': 'lab-home',
'/lab-bancada': 'lab-bancada',
'/marketplace-profissionais': 'marketplace-profissionais', '/marketplace-profissionais': 'marketplace-profissionais',
'/criar-clinica': 'criar-clinica', '/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config', '/rx-config': 'rx-config',
@@ -189,6 +194,8 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'minhas-salas': '/minhas-salas', 'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas', 'alugar-salas': '/alugar-salas',
'sala-home': '/sala-home', 'sala-home': '/sala-home',
'lab-home': '/lab-home',
'lab-bancada': '/lab-bancada',
'marketplace-profissionais': '/marketplace-profissionais', 'marketplace-profissionais': '/marketplace-profissionais',
'criar-clinica': '/criar-clinica', 'criar-clinica': '/criar-clinica',
'rx-config': '/rx-config', 'rx-config': '/rx-config',
@@ -287,6 +294,8 @@ const App: React.FC = () => {
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas'); if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações. // 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); 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);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais. // Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false; if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin. // Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
@@ -305,7 +314,7 @@ const App: React.FC = () => {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'], 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'], 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'], biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', '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'],
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'], 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'],
}; };
@@ -316,6 +325,8 @@ const App: React.FC = () => {
if (role === 'superadmin') return 'superadmin-overview'; if (role === 'superadmin') return 'superadmin-overview';
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala. // Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home'; if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
if (role === 'paciente') return 'tratamentos'; if (role === 'paciente') return 'tratamentos';
if (role === 'donosala') return 'minhas-salas'; if (role === 'donosala') return 'minhas-salas';
return 'dashboard'; return 'dashboard';
@@ -476,6 +487,8 @@ const App: React.FC = () => {
case 'minhas-salas': return <SalasPlugin view="minhas" />; case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />; case 'alugar-salas': return <SalasPlugin view="alugar" />;
case 'sala-home': return <SalaHomeView />; case 'sala-home': return <SalaHomeView />;
case 'lab-home': return <LabHomeView />;
case 'lab-bancada': return <ProteseView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />; case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />; case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />; case 'rx-config': return <RxConfigView />;
+14 -3
View File
@@ -37,9 +37,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' }); pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
} }
} }
// Locação de Salas: capability SEMPRE ativa (sem desativar) p/ todos menos superadmin. // Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário). // terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
if (currentRole !== 'superadmin') { // a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
// a locação. Por isso restringimos a papéis que podem ser titulares de sala.
const PAPEIS_LOCACAO = ['donosala', 'donoclinica', 'donoconsultorio', 'admin', 'dentista', 'biomedico', 'protetico'];
if (PAPEIS_LOCACAO.includes(currentRole)) {
pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' }); pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' });
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' }); pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
} }
@@ -58,6 +61,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign }, { id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell }, { id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen }, { 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: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard }, { id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon }, { id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users }, { id: 'pacientes', label: 'PACIENTES', icon: Users },
@@ -94,11 +99,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// PAINEL DA SALA só existe quando o workspace ativo é uma sala. // PAINEL DA SALA só existe quando o workspace ativo é uma sala.
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === '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';
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações. // 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.) // (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id); if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
// 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);
// plugins e gestão de tutores: exclusivo superadmin // plugins e gestão de tutores: exclusivo superadmin
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false; if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
+17
View File
@@ -1011,6 +1011,23 @@ export const HybridBackend = {
return await res.json().catch(() => ({ feitas: [], recebidas: [] })); return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
}, },
// 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`);
return await res.json().catch(() => []);
},
// Bloqueio de sala para manutenção (sem cobrança; tira a sala da agenda no intervalo).
criarManutencaoSala: async (salaId: string, body: { inicio: string; fim: string; motivo?: string }) => {
const res = await apiFetch(`${API_URL}/salas/${salaId}/manutencao`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || 'Falha ao bloquear a sala.');
return data;
},
saveFinanceiro: async (item: Partial<FinanceiroItem>) => { saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const data = enforceUppercase(item); const data = enforceUppercase(item);
+94 -1
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-react'; import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2, Plus, X } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx'; import { PageHeader } from '../components/PageHeader.tsx';
import { EmptyState } from '../components/EmptyState.tsx'; import { EmptyState } from '../components/EmptyState.tsx';
@@ -24,9 +24,43 @@ export const ClinicasView: React.FC = () => {
const currentRole = HybridBackend.getCurrentRole(); const currentRole = HybridBackend.getCurrentRole();
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica'; const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
// Papel da CONTA (não da unidade ativa) — é o que o backend usa para decidir
// se a nova unidade nasce como 'clinica' ou 'consultorio'.
const accountRole = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').role as string | undefined; }
catch { return undefined; }
})();
const isConsultorioAccount = accountRole === 'donoconsultorio';
const unitLabel = isConsultorioAccount ? 'CONSULTÓRIO' : 'CLÍNICA';
const canCreate = ['donoclinica', 'donoconsultorio', 'admin'].includes(accountRole || '');
const [isEditingColor, setIsEditingColor] = useState<string | null>(null); const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
const [savingColor, setSavingColor] = useState(false); const [savingColor, setSavingColor] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newNome, setNewNome] = useState('');
const [newDoc, setNewDoc] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newNome.trim()) return;
setCreating(true);
try {
const result = await HybridBackend.createClinica(newNome.trim(), newDoc.trim() || undefined);
if (!result.success) { toast.error(result.message || 'ERRO AO CRIAR UNIDADE.'); return; }
// Recarrega a lista COMPLETA do servidor (acrescenta a nova unidade às existentes,
// em vez de sobrescrever). A tela relê os workspaces no reload.
await HybridBackend.refreshWorkspaces();
toast.success(`${unitLabel} CRIADA!`);
setTimeout(() => window.location.reload(), 900);
} catch {
toast.error('ERRO DE CONEXÃO.');
} finally {
setCreating(false);
}
};
const handleSwitch = (workspaceId: string) => { const handleSwitch = (workspaceId: string) => {
if (activeWorkspace?.id === workspaceId) { if (activeWorkspace?.id === workspaceId) {
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE."); toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
@@ -81,6 +115,17 @@ export const ClinicasView: React.FC = () => {
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos." description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
/> />
{canCreate && (
<div className="flex justify-end -mt-4">
<button
onClick={() => { setNewNome(''); setNewDoc(''); setShowCreate(true); }}
className="inline-flex items-center gap-2 px-6 py-3 rounded-2xl bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-widest shadow-lg transition-colors"
>
<Plus size={16} /> Nova {unitLabel.toLowerCase()}
</button>
</div>
)}
{workspaces.length === 0 && ( {workspaces.length === 0 && (
<EmptyState icon={Building2} title="Nenhuma unidade ainda" description="Você ainda não tem clínicas ou consultórios vinculados a esta conta." /> <EmptyState icon={Building2} title="Nenhuma unidade ainda" description="Você ainda não tem clínicas ou consultórios vinculados a esta conta." />
)} )}
@@ -208,6 +253,54 @@ export const ClinicasView: React.FC = () => {
))} ))}
</div> </div>
</div> </div>
{showCreate && (
<div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => { if (!creating) setShowCreate(false); }}
>
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Nova {unitLabel.toLowerCase()}</h3>
<button
onClick={() => { if (!creating) setShowCreate(false); }}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl transition-colors"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">Nome do {unitLabel.toLowerCase()} *</label>
<input
autoFocus
required
value={newNome}
onChange={e => setNewNome(e.target.value.toUpperCase())}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium uppercase"
placeholder={isConsultorioAccount ? 'EX: DR. SILVA - CONSULTÓRIO' : 'EX: CLÍNICA SORRISO'}
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">CNPJ / CPF (opcional)</label>
<input
value={newDoc}
onChange={e => setNewDoc(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium"
placeholder="00.000.000/0000-00"
/>
</div>
<button
type="submit"
disabled={creating || !newNome.trim()}
className="w-full bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-xl font-black uppercase flex items-center justify-center gap-2 transition-all shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
>
{creating ? <><Loader2 className="animate-spin" size={18} /> Criando...</> : <><Plus size={18} /> Criar {unitLabel.toLowerCase()}</>}
</button>
</form>
</div>
</div>
)}
</div> </div>
); );
}; };
+3 -12
View File
@@ -10,17 +10,6 @@ function getStoredUser(): { nome?: string; role?: string } {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}'); } catch { return {}; } try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}'); } catch { return {}; }
} }
function applyWorkspace(result: any, userRole: string) {
const ws = {
id: result.clinica.id,
nome: result.clinica.nome_fantasia,
cor: '#2563eb',
role: userRole,
};
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([ws]));
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(ws));
}
export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated }) => { export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated }) => {
const storedUser = getStoredUser(); const storedUser = getStoredUser();
const userRole = storedUser.role || 'donoclinica'; const userRole = storedUser.role || 'donoclinica';
@@ -37,7 +26,9 @@ export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated })
const doCreate = async (nomeFinal: string, doc?: string) => { const doCreate = async (nomeFinal: string, doc?: string) => {
const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined); const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined);
if (!result.success) return result; if (!result.success) return result;
applyWorkspace(result, userRole); // Recarrega a lista do servidor (acrescenta a nova unidade às existentes em vez de
// sobrescrever). No onboarding o usuário tem só esta unidade, então vira a ativa.
await HybridBackend.refreshWorkspaces();
return result; return result;
}; };
+4 -2
View File
@@ -62,7 +62,9 @@ const PAGINAS_RBAC: Array<{ grupo: string; itens: Array<{ key: string; label: st
{ key: 'pacientes', label: 'PACIENTES' }, { key: 'pacientes', label: 'PACIENTES' },
{ key: 'ortodontia', label: 'ORTODONTIA' }, { key: 'ortodontia', label: 'ORTODONTIA' },
{ key: 'tratamentos', label: 'TRATAMENTOS' }, { key: 'tratamentos', label: 'TRATAMENTOS' },
{ key: 'proteses', label: 'PRÓTESES' },
{ key: 'orcamentos', label: 'ORÇAMENTOS' }, { key: 'orcamentos', label: 'ORÇAMENTOS' },
{ key: 'contratos', label: 'CONTRATOS' },
{ key: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' }, { key: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' },
], ],
}, },
@@ -94,8 +96,8 @@ const TODAS_PAGINAS = PAGINAS_RBAC.flatMap(g => g.itens.map(i => i.key));
// Modelos de cargo prontos (acelera a configuração). // Modelos de cargo prontos (acelera a configuração).
const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [ const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos'] }, { nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos', 'contratos', 'lancar-gto', 'proteses'] },
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos'] }, { nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos', 'proteses'] },
{ nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] }, { nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] },
{ nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] }, { nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] },
]; ];
+124
View File
@@ -0,0 +1,124 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } 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 });
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
export const LabHomeView: React.FC = () => {
const ws: any = HybridBackend.getActiveWorkspace();
const [os, setOs] = useState<any[]>([]);
const [fin, setFin] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getFinanceiro()]);
setOs(Array.isArray(b) ? b : []);
setFin(Array.isArray(f) ? f : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
const atrasadas = os.filter(o => o.atrasada).length;
const ymMes = new Date().toISOString().slice(0, 7);
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
// Faturamento do mês: RECEITA de prótese lançada no workspace do lab (espelho da entrega).
const fatMes = fin
.filter(l => l.origem === 'protese' && (l.data_competencia || l.datavencimento || '').slice(0, 7) === ymMes)
.reduce((s, l) => s + Number(l.valor || 0), 0);
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>
);
// Distribuição por status (fila de produção).
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
return (
<div className="space-y-6 pb-10">
<PageHeader title="PAINEL 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="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
<div className="min-w-0">
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
</div>
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
</div>
{/* Fila por status */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
<Clock size={16} className="text-teal-600" />
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
</div>
{porStatus.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
) : (
<div className="p-4 flex flex-wrap gap-3">
{porStatus.map(({ s, n }) => (
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
<p className="text-2xl font-black text-gray-800">{n}</p>
</div>
))}
</div>
)}
</div>
{/* Próximas entregas */}
<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">
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
</div>
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</div>
);
};
+172 -14
View File
@@ -32,6 +32,9 @@ interface GTOItem {
valorUnitario: number; valorUnitario: number;
valorTotal: number; valorTotal: number;
observacao?: string; observacao?: string;
// Prótese (Fase 1): item ligado ao catálogo do protético → gera OS ao finalizar.
produtoId?: string;
proteticoId?: string;
} }
interface GTOStore { interface GTOStore {
@@ -119,6 +122,14 @@ export const LancarGTO: React.FC = () => {
const [patientSearchOpen, setPatientSearchOpen] = useState(false); const [patientSearchOpen, setPatientSearchOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState(''); const [patientSearchTerm, setPatientSearchTerm] = useState('');
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
const [laboratorios, setLaboratorios] = useState<any[]>([]);
const [selectedLab, setSelectedLab] = useState<string>('');
const [labProdutos, setLabProdutos] = useState<any[]>([]);
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
const [protValor, setProtValor] = useState<string>('');
const [protObs, setProtObs] = useState<string>('');
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
const p = await HybridBackend.getPacientes(); const p = await HybridBackend.getPacientes();
@@ -140,14 +151,55 @@ export const LancarGTO: React.FC = () => {
return procedimentos.filter(p => p.especialidadeId === selectedEsp); return procedimentos.filter(p => p.especialidadeId === selectedEsp);
}, [selectedEsp, procedimentos]); }, [selectedEsp, procedimentos]);
const filteredPatients = useMemo(() => { // Especialidade de prótese → o "procedimento" vem do catálogo do protético (não da
if (!patientSearchTerm) return pacientes.slice(0, 30); // tabela procedimentos). Detecta pela área da especialidade (re-tagueada no backend).
const term = normalize(patientSearchTerm); const isProtese = useMemo(
return pacientes.filter(p => () => (especialidades.find(e => e.id === selectedEsp)?.area || '') === 'protese',
normalize(p.nome).includes(term) || [especialidades, selectedEsp]
(p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, ''))) );
).slice(0, 30); const selectedProduto = useMemo(
}, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps () => labProdutos.find(p => p.id === selectedProdutoId) || null,
[labProdutos, selectedProdutoId]
);
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
useEffect(() => {
if (!isProtese || laboratorios.length) return;
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
}, [isProtese, laboratorios.length]);
// Catálogo do laboratório selecionado.
useEffect(() => {
if (!selectedLab) { setLabProdutos([]); return; }
let active = true;
HybridBackend.getLaboratorioProdutos(selectedLab)
.then(p => { if (active) setLabProdutos(Array.isArray(p) ? p : []); })
.catch(() => { if (active) setLabProdutos([]); });
return () => { active = false; };
}, [selectedLab]);
// Busca de pacientes é SERVER-SIDE: a clínica pode ter milhares e a rota sem termo
// devolve só as 50 primeiras. Filtrar essas 50 no cliente fazia a busca "não achar"
// quem estava fora da 1ª página. Sem termo, mostra a lista inicial (recentes).
const [patientResults, setPatientResults] = useState<Paciente[]>([]);
const [searchingPatients, setSearchingPatients] = useState(false);
useEffect(() => {
const term = patientSearchTerm.trim();
if (!term) { setPatientResults(pacientes.slice(0, 30)); setSearchingPatients(false); return; }
let active = true;
setSearchingPatients(true);
const t = setTimeout(async () => {
try {
const r: any = await HybridBackend.getPacientes(term);
const list: Paciente[] = Array.isArray(r) ? r : (r?.data ?? []);
if (active) setPatientResults(list);
} finally {
if (active) setSearchingPatients(false);
}
}, 300);
return () => { active = false; clearTimeout(t); };
}, [patientSearchTerm, pacientes]);
useEffect(() => { useEffect(() => {
const { pendingSearch } = store; const { pendingSearch } = store;
@@ -250,6 +302,31 @@ export const LancarGTO: React.FC = () => {
setArcoObs(''); setArcoObs('');
}; };
// Adiciona um item de prótese (catálogo do protético) ao rascunho do GTO.
const handleAddProtese = () => {
if (!selectedLab) { toast.error("SELECIONE O LABORATÓRIO."); return; }
if (!selectedProduto) { toast.error("SELECIONE O PRODUTO DE PRÓTESE."); return; }
if (store.items.length >= 20) { toast.error("LIMITE DE 20 ITENS ATINGIDO."); return; }
const valor = parseFloat(protValor) || Number(selectedProduto.preco) || 0;
store.addItem({
id: Math.random().toString(36).substring(7),
procedimentoId: '',
codigoTUSS: '',
codigoInterno: '',
descricao: selectedProduto.nome,
quantidade: 1,
valorUnitario: valor,
valorTotal: valor,
observacao: protObs || undefined,
produtoId: selectedProduto.id,
proteticoId: selectedLab,
});
toast.success("PRÓTESE ADICIONADA AO RASCUNHO.");
setSelectedProdutoId('');
setProtValor('');
setProtObs('');
};
const handleFinalize = async () => { const handleFinalize = async () => {
if (!store.paciente || !store.dentista || store.items.length === 0) { if (!store.paciente || !store.dentista || store.items.length === 0) {
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS."); toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
@@ -276,9 +353,12 @@ export const LancarGTO: React.FC = () => {
credenciadaId: store.credenciada, credenciadaId: store.credenciada,
}); });
for (const it of store.items) { for (const it of store.items) {
const { arco, id: _localId, ...rest } = it as any; const { arco, id: _localId, produtoId, proteticoId, ...rest } = it as any;
void arco; void _localId; void arco; void _localId;
await HybridBackend.addGtoItem(id, rest); const payload: any = { ...rest };
// Prótese: envia produto/protético (snake_case) → o backend abre a OS na finalização.
if (produtoId) { payload.produto_id = produtoId; payload.protetico_id = proteticoId; }
await HybridBackend.addGtoItem(id, payload);
} }
const fin = await HybridBackend.finalizarGto(id); const fin = await HybridBackend.finalizarGto(id);
toast.success(fin?.financeiro_id toast.success(fin?.financeiro_id
@@ -390,6 +470,9 @@ export const LancarGTO: React.FC = () => {
setSelectedProc(null); setSelectedProc(null);
setSelectedDentes([]); setSelectedDentes([]);
setSelectedArco(null); setSelectedArco(null);
setSelectedProdutoId('');
setProtValor('');
setProtObs('');
}} }}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase" 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"
> >
@@ -397,8 +480,82 @@ export const LancarGTO: React.FC = () => {
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)} {especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
</select> </select>
{selectedEsp && ( {selectedEsp && isProtese && (
<div className="space-y-3">
<select
value={selectedLab}
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
>
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}</option>)}
</select>
{selectedLab && (labProdutos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
</div>
) : (
<>
<select
value={selectedProdutoId}
onChange={(e) => {
setSelectedProdutoId(e.target.value);
const pr = labProdutos.find(p => p.id === e.target.value);
setProtValor(pr ? String(pr.preco ?? '') : '');
}}
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">ESCOLHA A PRÓTESE...</option>
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} R$ {Number(p.preco || 0).toFixed(2)}</option>)}
</select>
{selectedProduto && (
<>
<div>
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Valor (R$)</label>
<input
type="number"
step="0.01"
value={protValor}
onChange={e => setProtValor(e.target.value)}
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-400 outline-none transition-all"
placeholder="0,00"
/>
</div>
<textarea
value={protObs}
onChange={e => setProtObs(e.target.value)}
rows={2}
placeholder="OBSERVAÇÃO PARA O PROTÉTICO (cor, material, dentes...)"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] focus:border-amber-400 outline-none transition-all resize-none"
/>
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
<ChevronRight size={10} /> AO FINALIZAR A GTO, UMA OS DE PRÓTESE SERÁ ABERTA NA BANCADA DO LABORATÓRIO.
</div>
</div>
<button
onClick={handleAddProtese}
className="w-full bg-[#2d6a4f] text-white py-4 rounded-2xl font-black text-sm uppercase shadow-lg shadow-green-100 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-2 group"
>
ADICIONAR PRÓTESE <ChevronRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
</>
)}
</>
))}
</div>
)}
{selectedEsp && !isProtese && (
<div className="space-y-2"> <div className="space-y-2">
{filteredProcedimentos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Nenhum procedimento cadastrado nesta especialidade</p>
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
</div>
) : (
<select <select
value={selectedProc?.id || ''} value={selectedProc?.id || ''}
onChange={(e) => { onChange={(e) => {
@@ -414,6 +571,7 @@ export const LancarGTO: React.FC = () => {
<option value="">BUSCAR PROCEDIMENTO</option> <option value="">BUSCAR PROCEDIMENTO</option>
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)} {filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select> </select>
)}
{selectedProc && ( {selectedProc && (
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100"> <div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
@@ -854,12 +1012,12 @@ export const LancarGTO: React.FC = () => {
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase" className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase"
/> />
<div className="max-h-72 overflow-y-auto space-y-1"> <div className="max-h-72 overflow-y-auto space-y-1">
{filteredPatients.length === 0 ? ( {patientResults.length === 0 ? (
<div className="py-10 text-center text-gray-300 font-bold uppercase text-xs"> <div className="py-10 text-center text-gray-300 font-bold uppercase text-xs">
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'} {searchingPatients ? 'BUSCANDO...' : (patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...')}
</div> </div>
) : ( ) : (
filteredPatients.map(p => ( patientResults.map(p => (
<button <button
key={p.id} key={p.id}
onClick={() => { onClick={() => {
+99 -11
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react'; import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays, Wrench, X } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx'; import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx';
@@ -14,7 +14,7 @@ const H_INI = 7, H_FIM = 22, ROW_H = 40; // 07h22h, 40px/hora
const segOf = (d: Date) => { const x = new Date(d); const dow = (x.getDay() + 6) % 7; x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - dow); return x; }; const segOf = (d: Date) => { const x = new Date(d); const dow = (x.getDay() + 6) % 7; x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - dow); return x; };
const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => { const AgendaSala: React.FC<{ reservas: any[]; onBloquear?: () => void }> = ({ reservas, onBloquear }) => {
const [weekStart, setWeekStart] = useState(() => segOf(new Date())); const [weekStart, setWeekStart] = useState(() => segOf(new Date()));
const dias = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; }); const dias = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; });
const ativas = reservas.filter(r => r.status === 'pendente' || r.status === 'confirmada'); const ativas = reservas.filter(r => r.status === 'pendente' || r.status === 'confirmada');
@@ -26,12 +26,17 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
if (!sameDay(ini, dia)) return null; if (!sameDay(ini, dia)) return null;
const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H); const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H);
const height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H); const height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H);
const manut = r.tipo === 'manutencao';
const conf = r.status === 'confirmada'; const conf = r.status === 'confirmada';
const cls = manut ? 'bg-gray-200 border-gray-400 text-gray-700'
: conf ? 'bg-teal-100 border-teal-300 text-teal-800'
: 'bg-amber-100 border-amber-300 text-amber-800';
const legenda = manut ? (r.motivo || 'Manutenção') : (r.solicitante_nome || r.clinica_nome || 'Reserva');
return ( return (
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${conf ? 'bg-teal-100 border-teal-300 text-teal-800' : 'bg-amber-100 border-amber-300 text-amber-800'}`} <div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${cls}`}
style={{ top, height }} title={`${r.solicitante_nome || r.clinica_nome || ''} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}> style={{ top, height }} title={`${legenda} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
<p className="text-[9px] font-black leading-tight truncate">{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p> <p className="text-[9px] font-black leading-tight truncate">{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
<p className="text-[9px] font-bold leading-tight truncate uppercase">{r.solicitante_nome || r.clinica_nome || 'Reserva'}</p> <p className="text-[9px] font-bold leading-tight truncate uppercase">{legenda}</p>
</div> </div>
); );
}); });
@@ -41,6 +46,11 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3"> <div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /> Agenda da sala</h3> <h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /> Agenda da sala</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onBloquear && (
<button onClick={onBloquear} className="flex items-center gap-1.5 bg-gray-700 text-white px-3 py-1.5 rounded-lg font-black text-[10px] uppercase hover:bg-gray-800 transition-colors">
<Wrench size={12} /> Bloquear manutenção
</button>
)}
<span className="text-[11px] font-black text-gray-500 uppercase">{label}</span> <span className="text-[11px] font-black text-gray-500 uppercase">{label}</span>
<button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() - 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronLeft size={16} /></button> <button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() - 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronLeft size={16} /></button>
<button onClick={() => setWeekStart(segOf(new Date()))} className="text-[10px] font-black uppercase text-teal-600 hover:bg-teal-50 px-2 py-1.5 rounded-lg">Hoje</button> <button onClick={() => setWeekStart(segOf(new Date()))} className="text-[10px] font-black uppercase text-teal-600 hover:bg-teal-50 px-2 py-1.5 rounded-lg">Hoje</button>
@@ -82,6 +92,63 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
<div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50"> <div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50">
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-teal-200 border border-teal-300" /> Confirmada</span> <span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-teal-200 border border-teal-300" /> Confirmada</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-200 border border-amber-300" /> Pendente</span> <span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-200 border border-amber-300" /> Pendente</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-gray-200 border border-gray-400" /> Manutenção</span>
</div>
</div>
);
};
// Modal: bloquear a sala para manutenção (data + faixa de horário + motivo).
const BloqueioModal: React.FC<{ onClose: () => void; onConfirm: (inicio: string, fim: string, motivo: string) => Promise<void> }> = ({ onClose, onConfirm }) => {
const toast = useToast();
const hojeYmd = new Date().toISOString().split('T')[0];
const [data, setData] = useState(hojeYmd);
const [hIni, setHIni] = useState('08:00');
const [hFim, setHFim] = useState('09:00');
const [motivo, setMotivo] = useState('');
const [saving, setSaving] = useState(false);
const salvar = async () => {
if (!data || !hIni || !hFim) { toast.error('PREENCHA DATA E HORÁRIOS.'); return; }
const inicio = new Date(`${data}T${hIni}`);
const fim = new Date(`${data}T${hFim}`);
if (fim <= inicio) { toast.error('O FIM DEVE SER DEPOIS DO INÍCIO.'); return; }
setSaving(true);
try { await onConfirm(inicio.toISOString(), fim.toISOString(), motivo.trim()); }
catch (e: any) { toast.error((e?.message || 'ERRO AO BLOQUEAR').toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-black text-sm uppercase text-gray-800 flex items-center gap-2"><Wrench size={16} className="text-gray-600" /> Bloquear p/ manutenção</h3>
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Data</label>
<input type="date" value={data} onChange={e => setData(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Início</label>
<input type="time" value={hIni} onChange={e => setHIni(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Fim</label>
<input type="time" value={hFim} onChange={e => setHFim(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo (opcional)</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Ex.: limpeza, conserto do equipo..." className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm text-gray-700 outline-none focus:border-teal-400" />
</div>
<button onClick={salvar} disabled={saving} className="w-full bg-gray-700 text-white py-3 rounded-xl font-black text-sm uppercase hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2">
{saving ? <RefreshCw size={16} className="animate-spin" /> : <Wrench size={16} />} Bloquear sala
</button>
</div>
</div> </div>
</div> </div>
); );
@@ -92,9 +159,12 @@ export const SalaHomeView: React.FC = () => {
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const ws: any = HybridBackend.getActiveWorkspace(); const ws: any = HybridBackend.getActiveWorkspace();
// Operador (ex.: recepção): só vê a AGENDA e bloqueia manutenção — sem locação/financeiro.
const isOperador = ws?.papel_sala === 'operador';
const [lancamentos, setLancamentos] = useState<any[]>([]); const [lancamentos, setLancamentos] = useState<any[]>([]);
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] }); const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [bloqueioOpen, setBloqueioOpen] = useState(false);
// Relatório por sala (período) // Relatório por sala (período)
const hoje = new Date(); const hoje = new Date();
const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]); const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]);
@@ -105,17 +175,25 @@ export const SalaHomeView: React.FC = () => {
const carregar = useCallback(async () => { const carregar = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
if (isOperador) {
// Operador não tem acesso ao financeiro/recebíveis: carrega só a agenda da sala.
const rs = await HybridBackend.getSalaReservas(ws.id);
setLancamentos([]);
setReservas({ feitas: [], recebidas: (Array.isArray(rs) ? rs : []).map((r: any) => ({ ...r, sala_id: ws.id })) });
} else {
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]); const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
setLancamentos(Array.isArray(fin) ? fin : []); setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] }); setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
}
} catch { /* silent */ } finally { setLoading(false); } } catch { /* silent */ } finally { setLoading(false); }
}, []); }, [isOperador, ws?.id]);
useEffect(() => { carregar(); }, [carregar]); useEffect(() => { carregar(); }, [carregar]);
const carregarRel = useCallback(() => { const carregarRel = useCallback(() => {
if (isOperador) { setRel(null); return; }
setRelLoading(true); setRelLoading(true);
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false)); HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
}, [inicio, fim]); }, [inicio, fim, isOperador]);
useEffect(() => { carregarRel(); }, [carregarRel]); useEffect(() => { carregarRel(); }, [carregarRel]);
const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0); const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0);
@@ -124,6 +202,13 @@ export const SalaHomeView: React.FC = () => {
const pendentes = reservasDaSala.filter(r => r.status === 'pendente').length; const pendentes = reservasDaSala.filter(r => r.status === 'pendente').length;
const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').length; const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').length;
const bloquearManutencao = async (inicio: string, fim: string, motivo: string) => {
await HybridBackend.criarManutencaoSala(ws.id, { inicio, fim, motivo });
toast.success('SALA BLOQUEADA PARA MANUTENÇÃO.');
setBloqueioOpen(false);
carregar();
};
const darBaixa = async (l: any) => { const darBaixa = async (l: any) => {
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return; if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
try { try {
@@ -160,15 +245,16 @@ export const SalaHomeView: React.FC = () => {
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : ( {loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<> <>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
<KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" /> {!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
<KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" /> {!isOperador && <KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />}
<KPI titulo="Reservas pendentes" valor={String(pendentes)} cor="text-red-500" Icon={Inbox} sub="Aguardando resposta" /> <KPI titulo="Reservas pendentes" valor={String(pendentes)} cor="text-red-500" Icon={Inbox} sub="Aguardando resposta" />
<KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" /> <KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
</div> </div>
<AgendaSala reservas={reservasDaSala} /> <AgendaSala reservas={reservasDaSala} onBloquear={() => setBloqueioOpen(true)} />
{!isOperador && (<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden"> <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 justify-between items-center"> <div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
<h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3> <h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3>
@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
</div> </div>
)} )}
</div> </div>
</>)}
</> </>
)} )}
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
</div> </div>
); );
}; };