Compare commits

..

4 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
6 changed files with 219 additions and 17 deletions
+59 -13
View File
@@ -634,9 +634,11 @@ app.post('/api/register', async (req, res) => {
: userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}`
: nome.trim();
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(
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, 'pessoal', $3)`,
[wsId, wsNome, userId]
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, $3, $4)`,
[wsId, wsNome, wsTipo, userId]
);
await pool.query(
'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)',
@@ -3782,6 +3784,15 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) =>
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).
// 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) {
if (!os) return { isLab: false, isClinic: false };
const isLab = !!uid && os.protetico_id === uid;
@@ -3904,10 +3915,11 @@ app.post('/api/protese/os', tenantGuard, async (req, res) => {
if (ehGarantia) custo = 0;
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const uid = req.authUser?.userId;
const labId = await labDoProtetico(b.protetico_id); // Fase 2: escopo por laboratório (empresa)
await pool.query(
`INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`,
[id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null,
`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,$23,'solicitado',$24)`,
[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,
tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses,
b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
@@ -3939,8 +3951,10 @@ app.get('/api/protese/os', tenantGuard, async (req, res) => {
app.get('/api/protese/bancada', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const vals = [uid];
let where = 'protetico_id=$1 AND deleted_at IS NULL';
const labId = await labDoProtetico(uid); // Fase 2: a bancada é do LABORATÓRIO (empresa)
const vals = [uid, labId];
// Roteia por laboratorio_id; fallback por pessoa cobre OS legadas sem laboratorio_id.
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2) OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
const { rows } = await pool.query(
`SELECT *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
@@ -3985,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) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' });
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia).
let garantiaAte = null, finId = os.financeiro_id || null;
let garantiaAte = null, finId = os.financeiro_id || null, finLabId = os.financeiro_lab_id || null;
if (novo === 'entregue') {
const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]);
if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' });
@@ -3996,13 +4010,27 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao)
`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)`,
[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]);
}
// 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]);
}
} 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) WHERE id=$4', [novo, garantiaAte, finId, os.id]);
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(
`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)]);
@@ -4476,10 +4504,11 @@ app.put('/api/gto-builder/:id/finalizar', authGuard, async (req, res) => {
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, 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,'nova',$11,$12,$13,$14,$15,'solicitado',$16,$17)`,
[osId, b.clinica_id, it.protetico_id, prot[0]?.nome || null, b.pacienteid, b.paciente_nome,
`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(
@@ -5686,6 +5715,23 @@ async function runMigrations() {
// 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 (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)
+11
View File
@@ -1,5 +1,16 @@
# 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**.
+3 -3
View File
@@ -9,7 +9,7 @@
### 1. Onde está o código?
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.19 · commit `715b2dc`**.
- **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).
### 2. Onde está o banco?
@@ -28,7 +28,7 @@
-**Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
### 5. Como versiona?
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.19 · `715b2dc` · 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.
-**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
@@ -39,4 +39,4 @@
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.19**), 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.
+14 -1
View File
@@ -31,6 +31,7 @@ import { ReportsView } from './views/ReportsView.tsx';
import { ComissoesView } from './views/ComissoesView.tsx';
import { GlosasView } from './views/GlosasView.tsx';
import { SalaHomeView } from './views/SalaHomeView.tsx';
import { LabHomeView } from './views/LabHomeView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
@@ -84,6 +85,8 @@ export type ViewKey =
| 'minhas-salas'
| 'alugar-salas'
| 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'marketplace-profissionais'
| 'criar-clinica'
| 'rx-config'
@@ -134,6 +137,8 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas',
'/sala-home': 'sala-home',
'/lab-home': 'lab-home',
'/lab-bancada': 'lab-bancada',
'/marketplace-profissionais': 'marketplace-profissionais',
'/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config',
@@ -189,6 +194,8 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas',
'sala-home': '/sala-home',
'lab-home': '/lab-home',
'lab-bancada': '/lab-bancada',
'marketplace-profissionais': '/marketplace-profissionais',
'criar-clinica': '/criar-clinica',
'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');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
@@ -305,7 +314,7 @@ const App: React.FC = () => {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', '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'],
};
@@ -316,6 +325,8 @@ const App: React.FC = () => {
if (role === 'superadmin') return 'superadmin-overview';
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
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 === 'donosala') return 'minhas-salas';
return 'dashboard';
@@ -476,6 +487,8 @@ const App: React.FC = () => {
case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />;
case 'sala-home': return <SalaHomeView />;
case 'lab-home': return <LabHomeView />;
case 'lab-bancada': return <ProteseView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
+8
View File
@@ -61,6 +61,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ 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: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
@@ -97,11 +99,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
if (item.id === 'lab-home' || item.id === 'lab-bancada') return (activeWorkspace as any)?.tipo === 'laboratorio';
// 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.)
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
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
+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>
);
};