Compare commits

...

52 Commits

Author SHA1 Message Date
VPS 4 Builder d83e0c82b2 feat(ui): dropdowns viram modal com busca (SearchSelect) na Avaliação/GTO e Nova OS
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Mesmo padrão do seletor de protético, generalizado para os demais dropdowns:

- Componente SearchSelect (modal genérico: trigger + busca + lista filtrável por rótulo/hint).
- LancarGTO: Especialidade/Área, Produto/Prótese, Procedimento, Dentista e Convênio/Credenciada
  — todos os <select> convertidos (0 selects restantes).
- Nova OS (ProteseView): Produto e Dentista convertidos.
- Cotação (RFQ): input de busca na lista de laboratórios a convidar (continua multi-seleção).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:29:49 +02:00
VPS 4 Builder e6d8b532e3 feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)
O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com
conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado:

- Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO
  (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um
  protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente.
- Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios
  passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e
  POST /protese/laboratorios/vincular.
- Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético
  SEM conta) mantido ao lado.

Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e,
como favorito+padrão, no topo da lista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:18:32 +02:00
VPS 4 Builder 8353a9d897 feat(protese): identificação por Nome+Sobrenome com desambiguação de homônimos
O protético identifica o trabalho pelo nome do paciente (etiqueta, sem CPF — ele não atende
paciente). Quando dois trabalhos têm o mesmo nome+sobrenome, desambigua sem expor dado sensível:

- util pacienteLabel.ts (rotulosPaciente / homonimoAtivo): comparação normalizada
  (case/acento/espaço-insensitive). Em colisão: 1) diferencia pela ORIGEM (dentista, senão
  clínica) → "João Silva · Dr. Carlos"; 2) se mesma origem, nº sequencial estável por data.
- Bancada (ProteseView) e Painel do lab (LabHomeView/Próximas entregas): nome exibido já
  desambiguado em toda a fila — vale para OS nascidas na clínica ou no lab.
- Modal "Dar entrada": campo único trocado por Nome + Sobrenome(s); aviso em tempo real quando
  há homônimo ativo ("acrescente outro sobrenome para diferenciar"), informando a origem do
  conflito.

Validado em DEV (3 homônimos "João Silva": origem distinta e mesma origem → sequencial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:03:24 +02:00
VPS 4 Builder 7c8c8fc0d2 feat(protese): fluxo do lado do protético — entrada de OS, baixa/cobrança e importar OS indicada
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
Fecha o ciclo operacional do laboratório, antes só "receptor passivo" das OS da clínica.

1. Dar entrada em OS pelo lab — POST /api/protese/lab/os: o dono do laboratório registra um
   trabalho recebido fora do app, escolhendo cliente existente OU avulso (clínica/consultório/
   dentista, materializado como registro mínimo) + produto do catálogo ou tipo livre. A OS já
   entra com laboratorio_id e custódia no laboratório. UI: botão "Dar entrada" + modal na LabHome.
2. Dar baixa em pagamento — já existia (lado 'lab'); mantido na PagamentosSecao.
3. Solicitar pagamento — POST /api/protese/os/:id/cobrar: calcula o saldo devedor (custo − pago)
   e notifica o cliente, registrando no histórico. UI: botão "Solicitar pagamento (saldo)" na
   PagamentosSecao do modo bancada.
4. Importar/vincular OS indicada — GET /api/protese/lab/os-indicadas (OS com protetico_id=me e
   laboratorio_id NULL) + POST /api/protese/os/:id/vincular-lab (seta laboratorio_id, status→recebido,
   notifica a clínica). Sem isso a OS só aparecia ao dono via fallback; importando, a equipe/técnicos
   e o financeiro do lab passam a vê-la. UI: seção "OS indicadas a você" na LabHome.

origemDe agora reconhece cliente avulso tipo 'dentista'. Validado em DEV (todos os 4 endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:43:35 +02:00
VPS 4 Builder 7566c01159 fix(protese): painel do lab usa fechamento próprio em vez do financeiro de pacientes (corrige 403)
LabHomeView chamava GET /api/financeiro (capability 'financeiro', negada ao protético) só para o
KPI "faturamento do mês" → 403 no console ao abrir o painel do laboratório. Passa a usar
GET /api/protese/lab/fechamento (custo das OS entregues no mês), que é do escopo do lab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:22:45 +02:00
VPS 4 Builder 87411e1566 feat(protese): CRM financeiro do laboratório (origem, contato, ticket, fechamento mensal)
Atende ao pedido de CRM financeiro do protético — antes as telas Clientes/Financeiro existiam mas
não distinguiam origem nem traziam relacionamento/fechamento.

- lab/clientes agora traz por cliente: ORIGEM (Clínica / Consultório / Dentista / Biomédico, derivada
  de clinicas.tipo + role do owner), CONTATO (telefone), TICKET MÉDIO, ÚLTIMO PEDIDO, além de
  OS/ativas/atrasadas/faturado/recebido/a-receber.
- Novo lab/fechamento: faturado x recebido por mês.
- LabClientesView reformulada: aba "Clientes (CRM)" com resumo por origem + tabela
  (origem/contato/ticket/últ. pedido/a-receber); aba "Financeiro" com KPIs + FECHAMENTO MENSAL + extrato.

Validado em DEV: cliente Clínica (a-receber 0) e Dentista avulso (a-receber 100); fechamento 06/2026
faturado 550 / recebido 450.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:07:11 +02:00
VPS 4 Builder 29b5b38678 fix(protese): protético acessa o Perfil/Marketplace para se publicar no diretório
Sem isso, o protético recém-cadastrado ficava invisível: o item PROFISSIONAIS não aparecia no
workspace laboratório (plugin não ativo) e o contexto laboratório bloqueava a tela — então ele não
tinha como publicar o perfil no diretório e nenhuma clínica o encontrava para enviar trabalho.

- Sidebar: PROFISSIONAIS aparece sempre para dentista/biomédico/protético (eles divulgam perfil),
  mesmo sem o plugin ativado.
- App: contexto 'laboratorio' libera marketplace-profissionais + diretorio-profissionais.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:56:01 +02:00
VPS 4 Builder 361f3809a7 docs(verdade-hoje): produção em v1.0.31 (4673f3c)
build-and-promote / build (push) Successful in 1m0s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:04:20 +02:00
VPS 4 Builder 4673f3c606 feat(protese): cadastro de protético interno (sem conta) + carteira no link mágico
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m2s
- Protético interno: a clínica cadastra um protético que não usa a plataforma. Cria usuário "mudo"
  (e-mail placeholder @interno.local + senha-fantasma; usuarios.email é NOT NULL/UNIQUE) + vínculo
  interno (fora do diretório). POST /protese/proteticos-internos; botão "Cadastrar protético interno"
  na Nova OS, que recarrega o dropdown e seleciona o novo.
- Carteira no link /p/TOKEN: botão "Meus trabalhos" → trabalhos em andamento/finalizados + mini
  financeiro mensal (valores pagos ao protético). GET /api/p/:token/carteira, escopo da relação
  clínica↔protético do token (não vaza outras clínicas; sem dados do paciente).

Validado em DEV: interno criado e acessível no dropdown; OS+link; carteira com andamento + R$250/2026-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:11:07 +02:00
VPS 4 Builder dc5a2d89ae feat(protese): agenda & coleta do laboratório (integrada à custódia)
Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB):
- protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora,
  endereço, responsável, status. Ligado à OS.
- POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica),
  POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente
  (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[].
- UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na
  aba Monitoramento da OS (clínica e lab).

Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:11:24 +02:00
VPS 4 Builder 7671199420 docs(verdade-hoje): produção em v1.0.30 (6cd8c1f)
build-and-promote / build (push) Successful in 1m3s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:00:41 +02:00
VPS 4 Builder 6cd8c1f9e1 feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m5s
- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS,
  reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe.
- Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações.
  Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos
  indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace).
- UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace.
- Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3).

Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:56:35 +02:00
VPS 4 Builder c084ad553b docs(verdade-hoje): produção em v1.0.29 (cd89ca2)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:03:13 +02:00
VPS 4 Builder cd89ca245c feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs;
cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS
(custo = valor da cotação) e a RFQ fecha (demais recusadas).

- Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id,
  POST /protese/rfq/:id/escolher (cria a OS).
- Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder.
- Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher.

Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:43:56 +02:00
VPS 4 Builder 07d798c370 docs(verdade-hoje): produção em v1.0.28 (6a88995)
build-and-promote / build (push) Successful in 50s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:00:31 +02:00
VPS 4 Builder 6a88995146 feat(protese): marketplace transacional fatia 2 — solicitar prótese cria a OS direto
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 47s
"Solicitar prótese" no card do protético abre o SolicitarProteseModal (lab pré-selecionado):
produto do catálogo (ou livre) + paciente + dentista + dentes + prazo + obs → cria a OS via
createProteseOS (que já aceita lab do diretório, sem vínculo prévio). Confirmação com CTA p/ a
Bancada. Modal auto-contido reusando getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS.

Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:56:33 +02:00
VPS 4 Builder f5a6dc7ea0 docs(verdade-hoje): produção em v1.0.27 (b5d093d)
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-24 07:52:30 +02:00
VPS 4 Builder b5d093d2ce feat(protese): marketplace transacional fatia 1 — vitrine de catálogo do laboratório
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
doc/MARKETPLACE-TRANSACIONAL-PROTESE: desenho do transacional (vitrine → solicitação → cotação
→ reputação), reusando catálogo/propostas/OS/score existentes.

Fatia 1: no card do protético no marketplace, "Ver catálogo de próteses" expande os produtos
(nome, preço, garantia) — reusa getLaboratorioProdutos (o catálogo já é acessível a quem está no
diretório, zero endpoint novo). Botão de contato vira "Solicitar prótese". A clínica passa a
decidir vendo serviços + preço + garantia + ★nota antes de contratar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:47:08 +02:00
VPS 4 Builder 489d2753ee docs(verdade-hoje): produção em v1.0.26 (d357f3a)
build-and-promote / build (push) Successful in 47s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:40:35 +02:00
VPS 4 Builder d357f3a2e8 feat(protese): Nota ScoreOdonto pública no marketplace de profissionais
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
GET /api/profissionais/marketplace passa a trazer a 'nota' dos protéticos (helper
notasScorePorProtetico, mesma fórmula da Fase 5 — só com volume mínimo). Card do protético
no marketplace exibe ★ nota. Ordem mantida por proximidade; a nota é comparação visível.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 2e90a2ba5f chore(protese): remove menu legado do protético (higiene)
O protético opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo).
Remove as telas clínicas legadas (agenda/lancar-gto/proteses/tratamentos/dashboard) do
fallback do protético em Sidebar.tsx e da lista de permissões em App.tsx; o fallback fora do
contexto laboratório fica mínimo (notificações/clínicas/config/diretório).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 53cf2c52e6 docs(verdade-hoje): produção em v1.0.25 (8040e70)
build-and-promote / build (push) Successful in 48s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:18:54 +02:00
VPS 4 Builder 8040e70310 feat(protese): Modo 1 fatia 1c — gestão do link na UI da clínica (status/acessos/revogar)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 56s
Fecha o Modo 1 (link seguro do protético sem conta):
- GET /api/protese/os/:id/link/status: link ativo, nº de acessos, último acesso e expiração.
- UI (aba Monitoramento, modo clínica): mostra status do link com Copiar / WhatsApp / Revogar
  e os contadores de acesso/expiração; gera sob demanda.

Conversão por herança é nativa (a OS já aponta para o protetico_id do destinatário). Validado em
DEV: acessos contados, expiração 60 dias, revogação → status inativo + leitura pública 404.

Modo 1 concluído (1a leitura + 1b ação + 1c gestão).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:12:03 +02:00
VPS 4 Builder 8552e87665 docs(verdade-hoje): produção em v1.0.24 (28dc27a)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:07:42 +02:00
VPS 4 Builder 28dc27a289 feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
Pelo link /p/TOKEN o protético (sem conta) passa a ATUAR na OS, tudo auditado como "<nome> (link)":
- POST /api/p/:token/custodia — confirmar recebimento / devolução (clínica↔lab)
- POST /api/p/:token/status — avançar a produção (recebido…finalizado); entregue/cancelado
  seguem exclusivos da clínica (400 pelo link)
- POST /api/p/:token/foto — anexar foto do trabalho
Página pública ganha a seção "Atualizar status" (custódia/etapa/foto) enquanto a OS não está
finalizada; header alterna Acompanhamento/Somente leitura.

Validado em DEV: recebimento, avanço de etapa, foto; entregue pelo link → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:03:52 +02:00
VPS 4 Builder f0b7a45d86 docs(verdade-hoje): produção em v1.0.23 (672c3b0)
build-and-promote / build (push) Successful in 59s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:57:18 +02:00
VPS 4 Builder 672c3b01e5 feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Provedor de Prótese SEM conta (doc/MODO1-LINK-SEGURO-PROTESE):

- Token por OS: tabela protese_os_link (aleatório 24B base64url, expira 60 dias, revogável,
  conta acessos). POST /protese/os/:id/link (gera/recupera) + POST .../link/revogar.
- Leitura pública GET /api/p/:token (sem auth): payload curado com a mesma blindagem soLab —
  SEM paciente_id (CPF) e SEM valor cobrado ao paciente; mostra trabalho, etapas, componentes,
  fotos, custódia, ocorrências, histórico e custo do lab.
- Página pública ProtesePublicView (mobile-first 320px+), interceptada no index.tsx antes do
  app autenticado; CTA "Criar conta grátis". Botão Gerar link / WhatsApp / Copiar na aba
  Monitoramento (modo clínica).
- Validado em DEV: leitura, blindagem (CPF/valor ocultos), token inválido→404, revogação→404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:53:02 +02:00
VPS 4 Builder 5f4460e37d feat(cadastro): card "Protético" na vitrine de onboarding + desenho do Modo 1
- OnboardingChat: adiciona o card de atalho "Protéticos" (perfil profissional → protetico),
  ao lado de Dentistas/Biomédicos. O cadastro de protético já funcionava via "Sou profissional",
  faltava o atalho na vitrine.
- doc/MODO1-LINK-SEGURO-PROTESE: desenho do "Provedor sem conta" — link seguro /p/TOKEN
  (token por OS, revogável; rotas públicas com a mesma blindagem soLab; página pública read+ação;
  CTA de conversão com herança de histórico). Evolução futura, sem refatoração.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:37:41 +02:00
VPS 4 Builder e05231663f docs(verdade-hoje): produção em v1.0.22 (84f4a79)
build-and-promote / build (push) Successful in 55s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:25:20 +02:00
VPS 4 Builder 84f4a797d2 feat(protese): Fase 5 — indicadores + Nota ScoreOdonto + ranking no marketplace
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Provedor de Prótese, Fase 5 (doc/PROVEDOR-PROTESE-DECISAO.md) — sobre a captura das fases 2-4:

- Indicadores do lab (GET /protese/lab/indicadores, só dono): entregues, tempo médio, atraso %,
  retrabalho % (garantia), ocorrências resolvidas %, componentes ausentes. Tela lab-indicadores.
- Nota ScoreOdonto (0-10) = 10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3), só com ≥3 entregas
  ("score em formação" abaixo disso). Apenas ocorrências RESOLVIDAS pesam (contraditório respeitado).
- Ranking: getProteseLaboratorios devolve a nota por protético e ordena internos→nota→nome; a
  clínica vê ★ nota no dropdown de Nova OS e Lançar GTO.

Roadmap do Provedor de Prótese concluído (Fases 1-5). Marketplace transacional e Modo 1 (link
seguro sem conta) ficam como evolução futura.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:49:32 +02:00
VPS 4 Builder ae48feaa8a feat(protese): Fase 4 — PF/PJ + CNPJ + Equipe (técnicos do laboratório)
Provedor de Prótese, Fase 4 (doc/PROVEDOR-PROTESE-DECISAO.md):

- PF/PJ: clinicas.pessoa_tipo (individual | laboratório) + documento (CPF/CNPJ) editáveis.
  Tela "Laboratório & Equipe" (lab-equipe). GET/PATCH /protese/lab.
- Equipe: novo vinculos.role='tecnico_protese'. Dono convida por e-mail (ensureUsuarioPorEmail
  → credencial temporária). GET/POST/DELETE /protese/lab/equipe (só dono).
- Acesso estendido: proteseOsAccesso reconhece o técnico (vínculo no laboratorio_id) e a bancada
  passa a usar labDoUsuario (dono OU técnico). Técnico opera a produção; menu reduzido e sem
  clientes/financeiro/equipe (endpoints retornam vazio/403). Gestão é exclusiva do dono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:40:10 +02:00
VPS 4 Builder c8d679eb70 feat(protese): Fase 3 — Clientes e Financeiro do laboratório (operação multi-clínica)
Provedor de Prótese, Fase 3 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Clientes (lab-clientes): clínicas que enviam OS, com OS totais/ativas/atrasadas/entregues,
  faturado e a-receber por cliente. GET /protese/lab/clientes (agrega por clinica_id no escopo
  do laboratório).
- Financeiro do lab (lab-financeiro): KPIs faturado/recebido/a-receber + extrato de recebimentos
  (pagamentos lado lab) com clínica/paciente/forma/data. GET /protese/lab/pagamentos.
  Regra: faturado = custo das OS entregues (≠ garantia); a-receber = faturado − recebido.
- Menu do laboratório ganha CLIENTES e FINANCEIRO (Sidebar + App rotas/contexto + nova
  LabClientesView com abas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:09:25 +02:00
VPS 4 Builder 3ffaff9d7d feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório
Provedor de Prótese, Fase 2 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência
  (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir.
  UI: badges de direção e conferência + botões inline na aba Itens & Valores.
- Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada,
  retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id).
- Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final.
  Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba
  "Ocorrências" (com contador) no modal da OS.
- Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:53:33 +02:00
VPS 4 Builder 7ae6993c4b feat(protese): Fase 1 — clínica de origem na OS + blindagem de CPF (LGPD)
Provedor de Prótese, Fase 1 do roadmap (doc/PROVEDOR-PROTESE-DECISAO.md):

- Origem da OS: bancada e detalhe passam a trazer clinica_nome (subquery em clinicas);
  o protético vê a clínica de origem na lista da bancada e no topo do modal. Destrava o
  cenário multi-clínica (Clínica A/B/C → mesmo laboratório).
- Blindagem de CPF (LGPD): confirmado que pacientes.id É o CPF. O provedor de prótese
  (lab que não é da clínica) deixa de receber paciente_id no detalhe e na bancada,
  mantendo paciente_nome; a clínica continua vendo o CPF para operar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:20:53 +02:00
VPS 4 Builder 9f103be397 feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente
- Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar
  para a próxima"). 'entregue' segue na Linha do tempo.
- Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o
  trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor
  cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico.
- Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve —
  GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também
  zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No
  front, o modo bancada esconde "Recebido do paciente".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:32:21 +02:00
VPS 4 Builder f4f433b14c feat(protese): avançar etapas pela clínica + recibo térmico/laser + abas reordenadas
- Aba "Linha do tempo" movida para o FINAL (entregue/cancelar continuam nela).
- Etapas agora avançáveis também pela clínica (não só pelo lab): nova lista "Etapas do
  trabalho" na 1ª aba, com botão "Avançar" por etapa. Backend: avanço da produção passa
  a aceitar lab OU clínica de origem (auditado por actor). 'entregue' segue na timeline
  (exige foto).
- Impressão por etapa em DOIS formatos: térmica (bobina 80mm) e laser (A4), com os dados
  da OS preenchidos no recibo (paciente, trabalho, dentista, lab, dentes, material/cor,
  componentes, prazo, local atual + assinaturas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:06:29 +02:00
VPS 4 Builder 8cf4a83161 feat(protese): modal da OS em abas + cupom de etapa + monitoramento de custódia
Reorganiza o gerenciamento da OS em ABAS (sem o scrollão único):
- Etapas: dados + stepper; substitui entregue/cancelar por "imprimir cupom" de cada
  etapa (comprovante imprimível que circula com o trabalho — janela de impressão).
- Linha do tempo: histórico com data/hora/autor; AQUI ficam marcar entregue / cancelar
  (e o fluxo do lab / acionar garantia).
- Itens & Valores: componentes, fotos e pagamentos.
- Monitoramento: custódia do trabalho (clínica ↔ laboratório) — "protético retirou" /
  "devolveu à clínica", com local atual em destaque e histórico de movimentações.

Backend: protese_os.local_atual + tabela protese_os_custodia + POST .../custodia
(valida transição, registra movimento e evento). GET detalhe expõe local_atual + custodia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:32:31 +02:00
VPS 4 Builder 623c9990d6 feat(protese): profissionaliza a OS — etapas, fotos, componentes, pagamentos e auditoria
Reformula o modal de detalhe da OS de prótese, fechando os gaps de gestão:

- Etapas: stepper visual do fluxo (solicitado→…→entregue) com a etapa atual e datas.
- Fotos: agora anexáveis TAMBÉM no modo clínica (antes só na bancada — beco sem saída
  para entregar), com autoria (quem anexou), data e legenda; remover foto. Toda foto
  entra no histórico.
- Componentes enviados: nova tabela protese_os_componente — item, qtd, quem enviou e
  quando (ex.: implante, pilar, modelo). Add/remover.
- Valores recebidos (dois lados): nova tabela protese_os_pagamento — lado='clinica'
  (paciente→clínica) e lado='lab' (clínica→lab), com totais, forma, autor e data.
- Auditoria: histórico com data+HORA e autor; foto/componente/pagamento viram eventos
  rastreados. Helpers carregaOSComAcesso/logEventoOS reaproveitados nos sub-recursos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:41:19 +02:00
VPS 4 Builder b80d061a6f docs(verdade-hoje): produção em v1.0.21 (064f36d)
build-and-promote / build (push) Successful in 1m1s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:21:43 +02:00
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
32 changed files with 5373 additions and 246 deletions
+1358 -30
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# Agenda & Coleta do laboratório
> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de
> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse).
## Conceito
- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório).
- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica).
- Cada agendamento tem data/hora, endereço, responsável, observação e status
(`agendada | realizada | cancelada`). Ligado a uma OS.
- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente
(coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado.
## Modelo
```sql
CREATE TABLE protese_coleta (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
laboratorio_id TEXT,
protetico_id TEXT,
tipo TEXT NOT NULL, -- coleta | entrega
data_hora TIMESTAMPTZ,
endereco TEXT,
responsavel TEXT,
observacao TEXT,
status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Endpoints
- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab).
- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo.
- `POST /api/protese/coleta/:id/status``realizada` (move a custódia) | `cancelada`.
- GET detalhe da OS passa a trazer `coletas[]`.
## UI
- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas
agendadas + entregas previstas (prazo da OS), com ação "marcar realizada".
- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico).
## Decisões
- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução.
- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia
e coleta em separado.
+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.
+58
View File
@@ -0,0 +1,58 @@
# Marketplace transacional de Prótese
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
## O que já existe (não recriar)
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
ScoreOdonto** no card (feito).
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
## Fatias
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
prótese"** (proposta com contexto de prótese).
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem
avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota ·
nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST
/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★,
verificado, em marketplace e indicadores.
**Marketplace transacional concluído (Fatias 1-4).**
## Decisões de modelagem
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
preservando rastreabilidade/financeiro/score.
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
+117
View File
@@ -0,0 +1,117 @@
# Modo 1 — Provedor de Prótese SEM conta (link seguro `/p/TOKEN`)
> Desenho (24/jun/2026). Evolução registrada como "fora de escopo" em `PROVEDOR-PROTESE-DECISAO.md`.
> Princípio (decisão arquitetural): **a clínica opera 100% da OS; o protético não precisa ter conta.**
> O link é a porta de entrada do ecossistema — acompanha hoje, vira conta amanhã.
## Objetivo
A clínica cria/gere a OS normalmente. O protético recebe um **link seguro** (WhatsApp/e-mail) e,
**sem login**, acompanha e atua na OS dele. Com CTA para criar conta grátis (conversão).
```
Clínica cria OS ──► gera link /p/TOKEN ──► WhatsApp/e-mail ──► Protético abre (sem conta)
├─ vê a OS (sem CPF/valores do paciente)
├─ confirma recebimento/devolução
├─ avança etapa / anexa foto
└─ responde ocorrência
"Crie sua conta grátis" ──► cadastro (card Protético)
```
## 1. Escopo do token: **por OS** (não por protético)
Escopo mínimo = menor superfície de risco. Um token dá acesso a **uma OS**. Vantagens: revogável
individualmente, expira por trabalho, não expõe a carteira inteira do protético. (Evolução: um token
"carteira" por protético-na-clínica que lista várias OS — fica para depois.)
## 2. Modelo de dados (revogável e auditável)
```sql
CREATE TABLE IF NOT EXISTS protese_os_link (
token TEXT PRIMARY KEY, -- aleatório longo (≥ 32 bytes base62), NÃO sequencial
os_id TEXT NOT NULL,
protetico_id TEXT, -- a quem se destina (rastreio)
created_by TEXT, -- quem na clínica gerou
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- opcional (ex.: +30 dias); null = sem expirar
revoked_at TIMESTAMPTZ, -- revogação manual
last_access_at TIMESTAMPTZ,
acessos INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id);
```
> Preferir tabela a JWT puro: permite **revogar** e **auditar acessos**. O token nunca contém dados.
## 3. Rotas públicas (sem `authGuard`; o token é a credencial)
Todas resolvem `os_id` pelo token (valida não-revogado, não-expirado) e aplicam a **mesma blindagem
do provedor**: sem `paciente_id`/CPF, sem valores do paciente (reusa a lógica `soLab`).
| Rota | Ação |
|---|---|
| `GET /api/p/:token` | OS: nº, clínica, dentista, paciente (nome, conforme permissão), prazo, status, observações, fotos, componentes, ocorrências, custódia. **Sem CPF/valores do paciente.** |
| `POST /api/p/:token/custodia` | confirmar recebimento / devolução (clínica↔lab) |
| `POST /api/p/:token/status` | avançar a produção (recebido→…→finalizado) |
| `POST /api/p/:token/foto` | anexar foto do trabalho (+ ocorrência) |
| `POST /api/p/:token/ocorrencias/:id/resposta` | responder ocorrência (contraditório) |
Cada ação registra no histórico da OS um **actor "Protético (link)"** com o `protetico_id` do token —
mantendo a auditoria. Mesmas validações de status/foto já existentes.
## 4. Geração e envio (lado clínica)
Na OS (modal, aba Etapas ou Monitoramento): botão **"Enviar ao protético"** →
`POST /api/protese/os/:id/link` gera/recupera o token e devolve a URL
`https://scoreodonto.com/p/TOKEN`. Atalhos: **WhatsApp** (`https://wa.me/?text=…`) e **e-mail**
(`mailto:`). Botão **"Revogar link"** para invalidar.
## 5. Frontend público `/p/:token`
Rota **fora do app autenticado** (sem Sidebar/login). Página enxuta que reusa as seções do
`OSDetailModal` em modo "público/lab" (etapas, fotos, componentes, custódia, ocorrências — sem
financeiro/clientes). Topo com a marca + rodapé fixo:
> *"Gostou de acompanhar por aqui? Crie sua conta grátis e gerencie todos os seus laboratórios."*
> → leva ao cadastro **já com o card Protético** (adicionado) e e-mail pré-preenchido.
## 6. Segurança (checklist)
- Token aleatório ≥ 32 bytes, base62, **não sequencial**; comparação em tempo constante.
- Escopo de **1 OS**; expiração opcional; **revogável**; rate-limit por token/IP.
- **Sem CPF e sem valores do paciente** (mesma blindagem `soLab` da Fase 1).
- Ações registram actor no histórico (auditoria); `last_access_at`/`acessos` para detectar abuso.
- OS `entregue`/`cancelado` → link vira **somente leitura**.
## 7. Conversão (o "porquê de negócio")
O link é aquisição: o protético experimenta o produto sem fricção e, ao criar conta, **herda o
histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). Liga direto ao
marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o score do protético.
## 8. Faseamento sugerido
- **✅ 1a (núcleo) — IMPLEMENTADA 24/jun:** tabela `protese_os_link` (token aleatório 24 bytes
base64url, expira em 60 dias, revogável); `POST /api/protese/os/:id/link` (gera/recupera) +
`POST …/link/revogar`; `GET /api/p/:token` (leitura pública curada — sem CPF, sem valor do
paciente; conta acessos). Página pública `/p/:token` (`ProtesePublicView`, mobile-first 320px+,
interceptada no `index.tsx` antes do app autenticado); botão "Gerar link / WhatsApp / Copiar" na
aba Monitoramento (modo clínica). CTA "Criar conta grátis". Validado em DEV (leitura, blindagem,
token inválido→404, revogação→404).
- **✅ 1b (ação) — IMPLEMENTADA 24/jun:** o protético, pelo link, **confirma recebimento/devolução**
(`POST /api/p/:token/custodia`), **avança a produção** (`/status`, só `recebido…finalizado` — nunca
entregue/cancelado, que são da clínica) e **anexa foto** (`/foto`). Tudo auditado como
`"<nome> (link)"`. Página pública ganha a seção "Atualizar status" (botões de custódia, etapa e foto)
enquanto a OS não está finalizada. Validado em DEV: recebimento, avanço, foto, e `entregue` pelo link
→ 400 (bloqueado).
- **✅ 1c (gestão) — IMPLEMENTADA 24/jun:** UI da clínica (aba Monitoramento) gere o link:
`GET /api/protese/os/:id/link/status` traz **link ativo, nº de acessos, último acesso e expiração**;
botões **Copiar / WhatsApp / Revogar**. Validado em DEV: acessos contados, expiração 60 dias,
revogação → status inativo + leitura pública 404.
> Conversão por herança: nativa — a OS já aponta para o `protetico_id` do destinatário, então ao
> entrar/criar conta ele já é dono do histórico. (Vincular um cadastro NOVO com e-mail diferente às
> OS é caso de borda — fica para evolução, evitando duplicar usuário.)
**Modo 1 concluído (1a leitura + 1b ação + 1c gestão).**
### ✅ Extras (24/jun)
- **Carteira pela link** (`GET /api/p/:token/carteira`): botão "Meus trabalhos" na página pública →
trabalhos em andamento + finalizados + **mini financeiro mensal** (valores pagos ao protético).
Escopo = a relação clínica↔protético da OS do token (não vaza outras clínicas; sem dados do paciente).
- **Protético INTERNO (sem conta)** (`POST /api/protese/proteticos-internos`): a clínica cadastra um
protético que não usa a plataforma — cria um usuário "mudo" (e-mail placeholder `..@interno.local`,
senha-fantasma; `usuarios.email` é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). Botão
"Cadastrar protético interno" na Nova OS. A partir daí recebe OS e acompanha pelo link, sem conta.
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
> camada de token público.
+121
View File
@@ -0,0 +1,121 @@
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
## Conceito
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
```
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
├── Profissional Individual (PF)
├── Laboratório (PJ)
└── Laboratório com Equipe (PJ + técnicos)
```
## Modos de operação
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
ocorrências. CTA: "crie sua conta ScoreOdonto".
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
Catálogo, Notificações, Perfil.
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
## Cadastro/Login
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
## Rastreabilidade de componentes (modelo)
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
A **ocorrência referencia o componente** (FK opcional).
## Ocorrências + Contraditório
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
categoria, descrição, fotos, evidências, anexos, responsável, status.
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
## Abas da OS (alvo)
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
## Indicadores (POR ÚLTIMO)
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
Score/ranking/marketplace. Não codar indicador antes de existir dado.
---
## Roadmap (5 fases)
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
(badge na lista e no modal). Destrava o cenário multi-clínica.
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun)
- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas,
atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`.
- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de
recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`.
Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado recebido.
- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config.
- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida).
- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato.
### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun)
- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ
(já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`.
- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail
(`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`.
- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada
usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro
(menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono.
- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê
clientes (0) e leva 403 ao tentar gerenciar equipe.
### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun)
- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das
fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia),
ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`.
- **Nota ScoreOdonto** (0-10): `10 (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de
3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado).
- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena
internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO.
- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a
clínica vê a nota no dropdown; "score em formação" abaixo do mínimo.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### Pós-roadmap (entregue depois das Fases 1-5)
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
laboratório; fallback mínimo fora dele.
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
Ordem mantida por proximidade; a nota é critério de comparação visível.
### Ainda em aberto (futuro)
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
+4 -4
View File
@@ -1,6 +1,6 @@
# 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.**
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
@@ -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.16 · commit `471c2c2`**.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.31 · commit `4673f3c`**.
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
### 2. Onde está o banco?
@@ -28,7 +28,7 @@
-**Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
### 5. Como versiona?
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.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.31 · `4673f3c` · PROD**.
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
-**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
@@ -39,4 +39,4 @@
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.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.31**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
+28 -15
View File
@@ -1,8 +1,8 @@
# Status — Segurança & Produção (ScoreOdonto)
> Resumo do que foi feito e do que falta. Atualizado em **19/jun/2026**.
> Produção no ar: **v1.0.18** (`fc00622`). **Todos os commits de código estão LOCAIS (sem `git push`)** —
> exceto a rotação do `JWT_SECRET`, que **já foi aplicada e verificada em produção**.
> Resumo do que foi feito e do que falta. Atualizado em **22/jun/2026**.
> Produção no ar: **v1.0.19** (`715b2dc`) — fila **A** concluída (push → CI → tag → deploy). **Commits
> 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
- **`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).
**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
### A) Levar o trabalho a produção (fila)
1. **Imagem limpa `v1.0.19`** (remove segredos embutidos na imagem):
`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.
### A) Levar o trabalho a produção (fila) — ✅ CONCLUÍDA
1. ~~**Imagem limpa `v1.0.19`**~~**FEITO** (ver "Fila A concluída" acima). Produção em `v1.0.19`/`715b2dc`.
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
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)
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)
> **13 commits desta sessão** (incluindo este documento) ainda **locais**. O repositório está
> `ahead 14` de `origin/main` — o 14º (`7c5b199 docs(LEIA)`) é anterior a esta sessão.
## Commits da sessão — **PUBLICADOS** (`git push` feito; produção em `v1.0.19`)
> Todos empurrados para `origin/main`. Promovidos a produção via tag `v1.0.19` (commit `715b2dc`).
> 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)
f22e30e docs(skills): completa as 62 skills
e83af04 docs(skills): biblioteca operacional
@@ -110,7 +119,11 @@ fcf2fb1 security(infra): .env fora do git + .dockerignore
```
## 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.
- 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>
+43 -1
View File
@@ -31,6 +31,12 @@ 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 { LabAgendaView } from './views/LabAgendaView.tsx';
import { LabClientesView } from './views/LabClientesView.tsx';
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
import { LabEquipeView } from './views/LabEquipeView.tsx';
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
@@ -84,6 +90,14 @@ export type ViewKey =
| 'minhas-salas'
| 'alugar-salas'
| 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'lab-agenda'
| 'lab-clientes'
| 'lab-financeiro'
| 'lab-cotacoes'
| 'lab-equipe'
| 'lab-indicadores'
| 'marketplace-profissionais'
| 'criar-clinica'
| 'rx-config'
@@ -134,6 +148,14 @@ 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',
'/lab-agenda': 'lab-agenda',
'/lab-clientes': 'lab-clientes',
'/lab-financeiro': 'lab-financeiro',
'/lab-cotacoes': 'lab-cotacoes',
'/lab-equipe': 'lab-equipe',
'/lab-indicadores': 'lab-indicadores',
'/marketplace-profissionais': 'marketplace-profissionais',
'/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config',
@@ -189,6 +211,14 @@ 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',
'lab-agenda': '/lab-agenda',
'lab-clientes': '/lab-clientes',
'lab-financeiro': '/lab-financeiro',
'lab-cotacoes': '/lab-cotacoes',
'lab-equipe': '/lab-equipe',
'lab-indicadores': '/lab-indicadores',
'marketplace-profissionais': '/marketplace-profissionais',
'criar-clinica': '/criar-clinica',
'rx-config': '/rx-config',
@@ -287,6 +317,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', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
@@ -305,7 +337,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: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
@@ -316,6 +348,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 +510,14 @@ 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 'lab-agenda': return <LabAgendaView />;
case 'lab-clientes': return <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-cotacoes': return <LabCotacoesView />;
case 'lab-equipe': return <LabEquipeView />;
case 'lab-indicadores': return <LabIndicadoresView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [labs, setLabs] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [addEmail, setAddEmail] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const preselected = useRef(false);
const carregar = useCallback(async (): Promise<any[]> => {
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
catch { setLabs([]); return []; }
}, []);
useEffect(() => { carregar(); }, [carregar]);
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
useEffect(() => {
if (preselected.current || value) return;
const pad = labs.find(l => l.padrao);
if (pad) { preselected.current = true; onChange(pad.id, pad); }
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = labs.find(l => l.id === value);
const q = busca.toLowerCase();
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
const toggleFav = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
catch { toast.error('ERRO'); }
};
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
catch { toast.error('ERRO'); }
};
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
const adicionar = async () => {
if (!addEmail.trim()) return;
setBusy(true);
try {
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
if (r?.error) throw new Error(r.error);
toast.success('PROTÉTICO VINCULADO.');
setAddEmail(''); setShowAdd(false);
const novos = await carregar();
const novo = novos.find((x: any) => x.id === r.protetico?.id);
if (novo) selecionar(novo);
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<>
<button type="button" onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
<span className="truncate flex items-center gap-2">
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
</span>
<Search size={16} className="text-gray-400 shrink-0" />
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-4 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
) : filtrados.map(l => (
<div key={l.id} onClick={() => selecionar(l)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
</button>
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
{l.nota != null && <span>· {l.nota}</span>}
{l.padrao && <span className="text-violet-600">· Padrão</span>}
</p>
</div>
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
</div>
))}
</div>
<div className="p-4 border-t border-gray-100">
{showAdd ? (
<div className="flex gap-2">
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
</button>
)}
</div>
</div>
</div>
)}
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+32 -5
View File
@@ -37,14 +37,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
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.
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário).
if (currentRole !== 'superadmin') {
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
// 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: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
}
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
}
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
@@ -58,6 +63,14 @@ 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: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
@@ -94,11 +107,23 @@ 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 (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
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') {
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
return ehTecnico
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
}
// plugins e gestão de tutores: exclusivo superadmin
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
@@ -128,7 +153,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { ProtesePublicView } from './views/ProtesePublicView.tsx';
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
import { ToastProvider } from './contexts/ToastContext.tsx';
import { ConfirmProvider } from './contexts/ConfirmContext.tsx';
@@ -34,13 +35,15 @@ window.addEventListener('vite:preloadError', () => {
const rootElement = document.getElementById('root');
if (rootElement) {
const root = createRoot(rootElement);
// Modo 1 — link seguro: /p/TOKEN é página PÚBLICA (sem login), fora do app autenticado.
const publicMatch = window.location.pathname.match(/^\/p\/(.+)$/);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<ToastProvider>
<ConfirmProvider>
<App />
{publicMatch ? <ProtesePublicView token={decodeURIComponent(publicMatch[1])} /> : <App />}
</ConfirmProvider>
</ToastProvider>
</ErrorBoundary>
+266 -1
View File
@@ -748,6 +748,18 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
return await res.json();
},
// Favorito (★) / padrão do protético na clínica ativa.
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Vincular um protético existente (com conta) como interno, por e-mail.
vincularProteticoEmail: async (email: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
return await res.json().catch(() => ({ error: 'falha' }));
},
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const qs = new URLSearchParams({ clinicaId });
@@ -778,13 +790,88 @@ export const HybridBackend = {
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
return await res.json();
},
uploadProteseFoto: async (id: string, file: File) => {
uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
const fd = new FormData();
fd.append('foto', file);
if (legenda) fd.append('legenda', legenda);
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
return await res.json();
},
// Conferência de componente (status: ok | divergente | ausente | pendente)
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
return await res.json();
},
// Ocorrências (contraditório)
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
return await res.json();
},
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
return await res.json();
},
statusProteseOcorrencia: async (ocId: string, status: string) => {
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
return await res.json();
},
deleteProteseFoto: async (fotoId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
return await res.json();
},
// Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
return await res.json();
},
deleteProteseComponente: async (compId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
return await res.json();
},
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
return await res.json();
},
deleteProtesePagamento: async (pagId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
return await res.json();
},
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
return await res.json();
},
// Editar campos do trabalho (cor, material, dentes, observações)
editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar');
return await res.json();
},
// Ajuste do valor cobrado do paciente (delta + ou )
ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor');
return await res.json();
},
// Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo
trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho');
return await res.json();
},
deleteProteseOS: async (id: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
@@ -1011,6 +1098,184 @@ export const HybridBackend = {
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
},
// Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos.
getLabClientes: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/clientes`);
return await res.json().catch(() => []);
},
getLabPagamentos: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
return await res.json().catch(() => []);
},
getLabFechamento: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
return await res.json().catch(() => []);
},
// Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório.
getLabOsIndicadas: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`);
return await res.json().catch(() => []);
},
vincularOsLab: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 6: solicitar pagamento (cobrar saldo) ao cliente.
cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso).
criarLabOs: async (body: any) => {
const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await res.json().catch(() => ({ error: 'falha' }));
},
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
getLabDados: async (): Promise<any> => {
const res = await apiFetch(`${API_URL}/protese/lab`);
return await res.json().catch(() => ({}));
},
updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => {
const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar');
return await res.json();
},
getLabEquipe: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe`);
return await res.json().catch(() => []);
},
addLabTecnico: async (body: { email: string; nome?: string }) => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico');
return await res.json();
},
removeLabTecnico: async (userId: string) => {
const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover');
return await res.json();
},
// Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados)
getLabIndicadores: async (): Promise<any> => {
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
return await res.json().catch(() => ({}));
},
// Agenda & Coleta do laboratório
agendarColeta: async (osId: string, body: { tipo: 'coleta' | 'entrega'; data_hora?: string; endereco?: string; responsavel?: string; observacao?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/coleta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao agendar');
return await res.json();
},
getLabAgenda: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/lab/agenda`);
return await res.json().catch(() => []);
},
statusColeta: async (coletaId: string, status: 'realizada' | 'cancelada') => {
const res = await apiFetch(`${API_URL}/protese/coleta/${coletaId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao atualizar');
return await res.json();
},
// Fatia 4: a clínica avalia o laboratório ao final da OS
avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avaliar');
return await res.json();
},
// Modo 1: link seguro de acompanhamento da OS
gerarProteseLink: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link');
return await res.json();
},
getProteseLinkStatus: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
return await res.json().catch(() => ({ ativo: false }));
},
// Cotação (RFQ) — Fatia 3
criarRfq: async (body: any) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao pedir cotação');
return await res.json();
},
getRfqs: async (): Promise<any[]> => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`);
return await res.json().catch(() => []);
},
getRfqRecebidas: async (): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/protese/rfq/recebidas`);
return await res.json().catch(() => []);
},
responderCotacao: async (cotacaoId: string, body: { valor: number; prazo_dias?: number; observacao?: string }) => {
const res = await apiFetch(`${API_URL}/protese/rfq/cotacao/${cotacaoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cotar');
return await res.json();
},
escolherCotacao: async (rfqId: string, cotacaoId: string) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/rfq/${rfqId}/escolher?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cotacao_id: cotacaoId }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao escolher');
return await res.json();
},
revogarProteseLink: async (osId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar');
return await res.json();
},
// Cadastro de protético INTERNO (sem conta) pela clínica
cadastrarProteticoInterno: async (body: { nome: string; telefone?: string; especialidade?: string }) => {
const cid = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/protese/proteticos-internos?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cadastrar');
return await res.json();
},
// Leitura PÚBLICA via token (sem login — não usa apiFetch/Authorization)
getProtesePublic: async (token: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.');
return await res.json();
},
getCarteiraPublic: async (token: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/carteira`);
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao carregar.');
return await res.json();
},
// Ações PÚBLICAS pelo link (Modo 1, fatia 1b) — sem login
custodiaPublic: async (token: string, para_local: 'clinica' | 'laboratorio') => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar.');
return await res.json();
},
statusPublic: async (token: string, status: string) => {
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avançar etapa.');
return await res.json();
},
uploadFotoPublic: async (token: string, file: File) => {
const fd = new FormData();
fd.append('foto', file);
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/foto`, { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto.');
return await res.json();
},
// 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>) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const data = enforceUppercase(item);
+51
View File
@@ -0,0 +1,51 @@
// Desambiguação de homônimos na bancada do protético.
// O protético identifica o trabalho pelo NOME do paciente (sem CPF — princípio de privacidade).
// Quando dois trabalhos têm o mesmo nome+sobrenome, diferenciamos sem expor dado sensível:
// 1) pela ORIGEM (dentista, senão clínica) — "João Silva · Dr. Carlos";
// 2) se ainda empatar (mesma origem), por nº sequencial estável — "João Silva · Dr. Carlos (2)".
// Etiqueta livre, nunca um cadastro/atendimento de paciente.
const DIACRITICOS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g');
const norm = (s?: string) =>
(s || '').normalize('NFD').replace(DIACRITICOS, '').toLowerCase().replace(/\s+/g, ' ').trim();
const origemDe = (o: any) => (o?.dentista_nome || o?.clinica_nome || o?.cliente_nome || '').trim();
// Recebe a lista de OS e devolve um Map id → rótulo de exibição já desambiguado.
export function rotulosPaciente(lista: any[]): Map<string, string> {
const grupos = new Map<string, any[]>();
for (const o of lista || []) {
const k = norm(o?.paciente_nome);
if (!k) continue;
const g = grupos.get(k);
if (g) g.push(o); else grupos.set(k, [o]);
}
const out = new Map<string, string>();
for (const o of lista || []) {
const base = (o?.paciente_nome || '').trim() || 'Sem identificação';
const k = norm(o?.paciente_nome);
const grupo = k ? (grupos.get(k) || [o]) : [o];
if (grupo.length <= 1) { out.set(o.id, base); continue; }
const origem = origemDe(o);
const mesmaOrigem = grupo.filter(g => norm(origemDe(g)) === norm(origem));
if (origem && mesmaOrigem.length === 1) {
out.set(o.id, `${base} · ${origem}`);
} else {
// mesma origem (ou origem ausente) → sequencial estável por data de criação
const ord = [...mesmaOrigem].sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
const idx = ord.findIndex(g => g.id === o.id) + 1;
out.set(o.id, origem ? `${base} · ${origem} (${idx})` : `${base} (${idx})`);
}
}
return out;
}
// Há outro trabalho ATIVO com o mesmo nome+sobrenome? (usado no aviso da entrada do lab)
// Retorna a origem do homônimo encontrado, ou null.
export function homonimoAtivo(nomeCompleto: string, lista: any[]): string | null {
const k = norm(nomeCompleto);
if (!k) return null;
const hit = (lista || []).find(o =>
norm(o?.paciente_nome) === k && !['entregue', 'cancelado'].includes(o?.status));
return hit ? (origemDe(hit) || 'outro cliente') : null;
}
+94 -1
View File
@@ -1,5 +1,5 @@
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 { PageHeader } from '../components/PageHeader.tsx';
import { EmptyState } from '../components/EmptyState.tsx';
@@ -24,9 +24,43 @@ export const ClinicasView: React.FC = () => {
const currentRole = HybridBackend.getCurrentRole();
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 [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) => {
if (activeWorkspace?.id === workspaceId) {
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."
/>
{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 && (
<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>
{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>
);
};
+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 {}; }
}
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 }) => {
const storedUser = getStoredUser();
const userRole = storedUser.role || 'donoclinica';
@@ -37,7 +26,9 @@ export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated })
const doCreate = async (nomeFinal: string, doc?: string) => {
const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined);
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;
};
+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: 'ortodontia', label: 'ORTODONTIA' },
{ key: 'tratamentos', label: 'TRATAMENTOS' },
{ key: 'proteses', label: 'PRÓTESES' },
{ key: 'orcamentos', label: 'ORÇAMENTOS' },
{ key: 'contratos', label: 'CONTRATOS' },
{ 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).
const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos'] },
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos'] },
{ 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', 'proteses'] },
{ nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] },
{ nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] },
];
+89
View File
@@ -0,0 +1,89 @@
import React, { useState, useEffect, useCallback } from 'react';
import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); };
// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas.
export const LabAgendaView: React.FC = () => {
const toast = useToast();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); }
catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const acao = async (id: string, status: 'realizada' | 'cancelada') => {
setBusy(id);
try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
};
// agrupa por dia (data_hora)
const grupos: Record<string, any[]> = {};
for (const c of lista.filter(x => x.status === 'agendada')) {
const k = (c.data_hora || '').slice(0, 10) || 'sem-data';
(grupos[k] = grupos[k] || []).push(c);
}
const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10);
const Card: React.FC<{ c: any }> = ({ c }) => {
const coleta = c.tipo === 'coleta';
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-start gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 ${coleta ? 'bg-violet-600' : 'bg-blue-600'}`}>{coleta ? <Truck size={18} /> : <PackageCheck size={18} />}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${coleta ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{coleta ? 'Coleta' : 'Entrega'}</span>
<span className="text-[11px] font-black text-gray-500">{dataHoraBR(c.data_hora)}</span>
</div>
<p className="font-bold text-gray-800 text-sm truncate mt-1">{c.os_tipo} {c.paciente_nome ? <span className="text-gray-400 font-medium">· {c.paciente_nome}</span> : ''}</p>
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}</p>
{c.endereco && <p className="text-[11px] text-gray-400 flex items-center gap-1 mt-0.5"><MapPin size={11} /> {c.endereco}</p>}
{c.responsavel && <p className="text-[11px] text-gray-400 flex items-center gap-1"><User size={11} /> {c.responsavel}</p>}
{c.status === 'agendada' && (
<div className="flex gap-2 mt-2">
<button disabled={busy === c.id} onClick={() => acao(c.id, 'realizada')} className="px-3 py-1.5 rounded-lg bg-[#2d6a4f] text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><CheckCircle2 size={12} /> Realizada</button>
<button disabled={busy === c.id} onClick={() => acao(c.id, 'cancelada')} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-[10px] font-black uppercase disabled:opacity-50">Cancelar</button>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="AGENDA DE COLETAS">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
: lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma coleta ou entrega agendada.</div>
: (
<div className="space-y-5">
{Object.keys(grupos).sort().map(dia => (
<div key={dia}>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><CalendarClock size={12} /> {dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}</p>
<div className="space-y-2">{grupos[dia].map(c => <Card key={c.id} c={c} />)}</div>
</div>
))}
{realizadas.length > 0 && (
<div>
<p className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-2">Realizadas (recentes)</p>
<div className="space-y-2 opacity-60">{realizadas.map(c => <Card key={c.id} c={c} />)}</div>
</div>
)}
</div>
)}
</div>
);
};
+158
View File
@@ -0,0 +1,158 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : m; };
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
'Clínica': { icon: Building2, cls: 'bg-blue-100 text-blue-700' },
'Consultório': { icon: Briefcase, cls: 'bg-teal-100 text-teal-700' },
'Dentista': { icon: Stethoscope, cls: 'bg-violet-100 text-violet-700' },
'Biomédico': { icon: User, cls: 'bg-pink-100 text-pink-700' },
'Profissional': { icon: User, cls: 'bg-gray-100 text-gray-600' },
};
// CRM Financeiro do laboratório: Clientes (por origem, com contato/ticket/recebíveis) + Financeiro
// (fechamento mensal + recebimentos).
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
const [clientes, setClientes] = useState<any[]>([]);
const [pagamentos, setPagamentos] = useState<any[]>([]);
const [fechamento, setFechamento] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => { setAba(tab); }, [tab]);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
// resumo por origem
const porOrigem: Record<string, number> = {};
for (const c of clientes) porOrigem[c.origem] = (porOrigem[c.origem] || 0) + 1;
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
</div>
);
return (
<div className="space-y-6 pb-10">
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
<div className="flex border-b border-gray-100">
{(['clientes', 'financeiro'] as const).map(t => (
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
))}
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
<>
{aba === 'clientes' && (
clientes.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div> : (
<>
{/* resumo por origem */}
<div className="flex flex-wrap gap-2">
{Object.entries(porOrigem).map(([o, n]) => {
const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon;
return <span key={o} className={`text-[10px] font-black px-3 py-1.5 rounded-full uppercase flex items-center gap-1.5 ${m.cls}`}><I size={12} /> {o}: {n}</span>;
})}
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{clientes.map(c => {
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
return (
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{c.clinica_nome || '—'}</td>
<td className="px-4 py-3"><span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${m.cls}`}>{c.origem}</span></td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{c.contato ? <span className="flex items-center gap-1"><Phone size={11} /> {c.contato}</span> : '—'}</td>
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</td>
<td className="px-4 py-3 text-gray-600">{fmtBRL(c.ticket_medio)}</td>
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'}</td>
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)
)}
{aba === 'financeiro' && (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
</div>
{/* Fechamento mensal */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Fechamento mensal</h3></div>
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{fechamento.map(m => (
<tr key={m.mes} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-black text-gray-700 uppercase">{mesBR(m.mes)}</td>
<td className="px-4 py-3 text-gray-600">{m.entregues}</td>
<td className="px-4 py-3 font-bold text-gray-800">{fmtBRL(m.faturado)}</td>
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(m.recebido)}</td>
<td className={`px-4 py-3 font-black ${m.faturado - m.recebido > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
{/* Extrato */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3><span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length}</span></div>
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-gray-100">
{pagamentos.map(p => (
<tr key={p.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues ( garantia). A receber = faturado recebido.</p>
</>
)}
</>
)}
</div>
);
};
+85
View File
@@ -0,0 +1,85 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
export const LabCotacoesView: React.FC = () => {
const toast = useToast();
const [lista, setLista] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
const [busy, setBusy] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
const responder = async (c: any) => {
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
const valor = parseFloat(f.valor);
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
setBusy(c.cotacao_id);
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
};
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="PEDIDOS DE COTAÇÃO">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
: (
<div className="space-y-3">
{lista.map(c => {
const aberta = c.rfq_status === 'aberta';
const ganhou = c.cotacao_status === 'escolhida';
const perdeu = c.cotacao_status === 'recusada';
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
return (
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
</div>
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
</div>
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
{aberta && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
</div>
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
</div>
)}
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
</div>
);
})}
</div>
)}
</div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React, { useState, useEffect, useCallback } from 'react';
import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos.
export const LabEquipeView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const [dados, setDados] = useState<any>(null);
const [equipe, setEquipe] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [email, setEmail] = useState('');
const [nome, setNome] = useState('');
const [cred, setCred] = useState<{ email: string; senha: string } | null>(null);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]);
setDados(d || {});
setEquipe(Array.isArray(e) ? e : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const salvarDados = async () => {
if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; }
setBusy(true);
try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const addTecnico = async () => {
if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; }
setBusy(true);
try {
const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined });
toast.success('TÉCNICO ADICIONADO.');
if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword });
setEmail(''); setNome(''); carregar();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const remover = async (u: any) => {
if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return;
try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
if (loading || !dados) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
const ehPJ = dados.pessoa_tipo === 'PJ';
return (
<div className="space-y-6 pb-10 max-w-2xl">
<PageHeader title="LABORATÓRIO & EQUIPE">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{/* Dados do provedor */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
<div className="flex gap-2">
{(['PF', 'PJ'] as const).map(t => (
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
</button>
))}
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
<input value={dados.nome_fantasia || ''} onChange={e => setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
<input value={dados.documento || ''} onChange={e => setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
</div>
{/* Equipe */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção sem acesso a clientes, financeiro ou valores do paciente.</p>
{cred && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
</div>
)}
<div className="space-y-1.5">
{equipe.map(u => (
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
</div>
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
</div>
))}
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
</div>
<div className="flex gap-2">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
</div>
</div>
</div>
);
};
+279
View File
@@ -0,0 +1,279 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
import { useToast } from '../contexts/ToastContext.tsx';
import { rotulosPaciente, homonimoAtivo } from '../utils/pacienteLabel.ts';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const CLI_TIPO = [
{ v: 'clinica', label: 'Clínica', icon: Building2 },
{ v: 'consultorio', label: 'Consultório', icon: Briefcase },
{ v: 'dentista', label: 'Dentista', icon: Stethoscope },
];
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
export const LabHomeView: React.FC = () => {
const ws: any = HybridBackend.getActiveWorkspace();
const toast = useToast();
const [os, setOs] = useState<any[]>([]);
const [fin, setFin] = useState<any[]>([]);
const [indicadas, setIndicadas] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [showEntrada, setShowEntrada] = useState(false);
const [importando, setImportando] = useState<string | null>(null);
const carregar = useCallback(async () => {
setLoading(true);
try {
const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]);
setOs(Array.isArray(b) ? b : []);
setFin(Array.isArray(f) ? f : []);
setIndicadas(Array.isArray(ind) ? ind : []);
} catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const importar = async (osId: string) => {
setImportando(osId);
try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); }
};
const rotuloPac = useMemo(() => rotulosPaciente(os), [os]);
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
const atrasadas = os.filter(o => o.atrasada).length;
const ymMes = new Date().toISOString().slice(0, 7);
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
// Faturamento do mês: do fechamento do próprio lab (custo das OS entregues no mês).
const fatMes = Number(fin.find(m => m.mes === ymMes)?.faturado || 0);
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
<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={() => setShowEntrada(true)} className="text-white px-3 py-2 rounded-lg flex items-center gap-2 text-xs font-black uppercase shadow-sm" style={{ backgroundColor: '#2d6a4f' }}>
<Plus size={16} /> <span className="hidden sm:inline">Dar entrada</span>
</button>
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
</button>
</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>
{/* OS indicadas ao laboratório aguardando importação */}
{indicadas.length > 0 && (
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-amber-100 bg-amber-50/60 flex items-center gap-2">
<ArrowDownToLine size={16} className="text-amber-600" />
<h3 className="text-sm font-black text-amber-800 uppercase">OS indicadas a você ({indicadas.length})</h3>
<span className="text-[10px] font-bold text-amber-500 uppercase ml-auto hidden sm:inline">Importe para entrar na produção e financeiro</span>
</div>
<div className="divide-y divide-gray-50">
{indicadas.map(o => (
<div key={o.id} className="px-6 py-3 flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 uppercase truncate text-sm">{o.tipo}</p>
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}</p>
</div>
<span className="text-sm font-black text-gray-700 hidden sm:block">{fmtBRL(o.custo)}</span>
<button disabled={importando === o.id} onClick={() => importar(o.id)} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5 shrink-0">
<ArrowDownToLine size={13} /> Importar
</button>
</div>
))}
</div>
</div>
)}
{/* Fila por status */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
<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">{rotuloPac.get(o.id) || o.paciente_nome || '—'}</td>
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
{showEntrada && <LabEntradaModal osAtivas={os} onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
</div>
);
};
// ── Modal: dar entrada em OS pelo lado do laboratório ────────────────────────
const LabEntradaModal: React.FC<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
const toast = useToast();
const [clientes, setClientes] = useState<any[]>([]);
const [produtos, setProdutos] = useState<any[]>([]);
const [busy, setBusy] = useState(false);
// origem do cliente: existente ou avulso
const [clienteId, setClienteId] = useState(''); // '' = avulso
const [cliNome, setCliNome] = useState('');
const [cliTipo, setCliTipo] = useState('clinica');
const [produtoId, setProdutoId] = useState(''); // '' = tipo livre
const [tipo, setTipo] = useState('');
const [custo, setCusto] = useState('');
const [prazo, setPrazo] = useState('');
const [nome, setNome] = useState('');
const [sobrenome, setSobrenome] = useState('');
const [obs, setObs] = useState('');
// Identificação do trabalho = Nome + Sobrenome (etiqueta, sem CPF). Avisa sobre homônimos ativos.
const pacienteNome = `${nome} ${sobrenome}`.replace(/\s+/g, ' ').trim();
const homonimo = useMemo(() => pacienteNome ? homonimoAtivo(pacienteNome, osAtivas) : null, [pacienteNome, osAtivas]);
useEffect(() => {
HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {});
HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {});
}, []);
const salvar = async () => {
if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; }
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
setBusy(true);
try {
const body: any = { paciente_nome: pacienteNome || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; }
if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; }
const r = await HybridBackend.criarLabOs(body);
if (r?.error) throw new Error(r.error);
toast.success('ENTRADA REGISTRADA.');
onSaved();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide';
const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400';
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> Dar entrada em OS</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-5 space-y-4">
{/* Cliente */}
<div>
<label className={lbl}>Cliente</label>
<select value={clienteId} onChange={e => setClienteId(e.target.value)} className={inp}>
<option value=""> Novo cliente avulso</option>
{clientes.map(c => <option key={c.clinica_id} value={c.clinica_id}>{c.clinica_nome} ({c.origem})</option>)}
</select>
{!clienteId && (
<div className="mt-2 space-y-2 bg-gray-50 rounded-xl p-3">
<input value={cliNome} onChange={e => setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} />
<div className="flex gap-2">
{CLI_TIPO.map(t => { const I = t.icon; return (
<button key={t.v} onClick={() => setCliTipo(t.v)} className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1 border ${cliTipo === t.v ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-200 text-gray-400'}`}><I size={12} /> {t.label}</button>
); })}
</div>
</div>
)}
</div>
{/* Produto */}
<div>
<label className={lbl}>Produto</label>
<select value={produtoId} onChange={e => setProdutoId(e.target.value)} className={inp}>
<option value=""> Tipo livre</option>
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} {fmtBRL(Number(p.preco || 0))}</option>)}
</select>
{!produtoId && (
<div className="mt-2 flex gap-2">
<input value={tipo} onChange={e => setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} />
<input value={custo} onChange={e => setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} />
</div>
)}
</div>
{/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
<div>
<label className={lbl}>Identificação do trabalho (paciente)</label>
<div className="grid grid-cols-2 gap-3 mt-1">
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} />
<input value={sobrenome} onChange={e => setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} />
</div>
{homonimo && (
<p className="mt-1.5 text-[10px] font-bold text-amber-600 uppercase flex items-start gap-1">
<AlertTriangle size={12} className="shrink-0 mt-px" /> existe {pacienteNome} ativo ({homonimo}). Acrescente outro sobrenome para diferenciar.
</p>
)}
</div>
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
<div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div>
<button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button>
<p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra com o laboratório (custódia) e no seu financeiro.</p>
</div>
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2, BadgeCheck, Star } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { PageHeader } from '../components/PageHeader.tsx';
// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto.
export const LabIndicadoresView: React.FC = () => {
const [ind, setInd] = useState<any>(null);
const [loading, setLoading] = useState(false);
const carregar = useCallback(async () => {
setLoading(true);
try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
</div>
);
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
const temNota = ind.nota !== null && ind.nota !== undefined;
const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500';
return (
<div className="space-y-6 pb-10">
<PageHeader title="INDICADORES DO LABORATÓRIO">
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
</PageHeader>
{/* Nota ScoreOdonto */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
<Award size={18} />
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
{ind.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5"><BadgeCheck size={11} /> Verificado</span>}
</div>
{temNota ? (
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Sobre {ind.entregues} entrega(s){ind.avaliacoes ? ` · ${ind.satisfacao}★ de ${ind.avaliacoes} avaliações` : ''}. Combina desempenho (atraso/retrabalho/ocorrências) e satisfação da clínica.</p>
) : (
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação mínimo de {ind.min_os} entregas para gerar a nota.</p>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
<KPI titulo="Satisfação" valor={ind.satisfacao != null ? `${ind.satisfacao}` : '—'} cor="text-amber-500" Icon={Star} sub={`${ind.avaliacoes || 0} avaliação(ões)`} />
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). ocorrências <b>resolvidas</b> (após contraditório) entram abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
</div>
</div>
);
};
+189 -52
View File
@@ -13,6 +13,8 @@ import {
X
} from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx';
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
import { SearchSelect } from '../components/SearchSelect.tsx';
import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
import { useToast } from '../contexts/ToastContext.tsx';
@@ -32,6 +34,9 @@ interface GTOItem {
valorUnitario: number;
valorTotal: number;
observacao?: string;
// Prótese (Fase 1): item ligado ao catálogo do protético → gera OS ao finalizar.
produtoId?: string;
proteticoId?: string;
}
interface GTOStore {
@@ -119,6 +124,13 @@ export const LancarGTO: React.FC = () => {
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState('');
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
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(() => {
const load = async () => {
const p = await HybridBackend.getPacientes();
@@ -140,14 +152,49 @@ export const LancarGTO: React.FC = () => {
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
}, [selectedEsp, procedimentos]);
const filteredPatients = useMemo(() => {
if (!patientSearchTerm) return pacientes.slice(0, 30);
const term = normalize(patientSearchTerm);
return pacientes.filter(p =>
normalize(p.nome).includes(term) ||
(p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, '')))
).slice(0, 30);
}, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps
// Especialidade de prótese → o "procedimento" vem do catálogo do protético (não da
// tabela procedimentos). Detecta pela área da especialidade (re-tagueada no backend).
const isProtese = useMemo(
() => (especialidades.find(e => e.id === selectedEsp)?.area || '') === 'protese',
[especialidades, selectedEsp]
);
const selectedProduto = useMemo(
() => labProdutos.find(p => p.id === selectedProdutoId) || null,
[labProdutos, selectedProdutoId]
);
// 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(() => {
const { pendingSearch } = store;
@@ -250,6 +297,31 @@ export const LancarGTO: React.FC = () => {
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 () => {
if (!store.paciente || !store.dentista || store.items.length === 0) {
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
@@ -276,9 +348,12 @@ export const LancarGTO: React.FC = () => {
credenciadaId: store.credenciada,
});
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;
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);
toast.success(fin?.financeiro_id
@@ -383,37 +458,104 @@ export const LancarGTO: React.FC = () => {
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
</div>
<div className="space-y-3">
<select
<SearchSelect
value={selectedEsp}
onChange={(e) => {
setSelectedEsp(e.target.value);
onChange={(v) => {
setSelectedEsp(v);
setSelectedProc(null);
setSelectedDentes([]);
setSelectedArco(null);
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"
>
<option value="">ESCOLHA A ÁREA...</option>
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
</select>
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
/>
{selectedEsp && (
{selectedEsp && isProtese && (
<div className="space-y-3">
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
{selectedLab && (labProdutos.length === 0 ? (
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
</div>
) : (
<>
<SearchSelect
value={selectedProdutoId}
onChange={(v) => {
setSelectedProdutoId(v);
const pr = labProdutos.find(p => p.id === v);
setProtValor(pr ? String(pr.preco ?? '') : '');
}}
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
/>
{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">
<select
value={selectedProc?.id || ''}
onChange={(e) => {
const pr = procedimentos.find(p => p.id === e.target.value);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
>
<option value="">BUSCAR PROCEDIMENTO</option>
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
</select>
{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>
) : (
<SearchSelect
value={selectedProc?.id || ''}
onChange={(v) => {
const pr = procedimentos.find(p => p.id === v);
setSelectedProc(pr || null);
setSelectedDentes([]);
setSelectedArco(null);
setArcoFace('');
setArcoObs('');
}}
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
/>
)}
{selectedProc && (
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
@@ -443,28 +585,23 @@ export const LancarGTO: React.FC = () => {
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
</div>
<select
<SearchSelect
value={store.dentista?.id || ''}
onChange={(e) => {
const d = dentistas.find(d => d.id === e.target.value);
store.setDentista(d || null);
}}
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O PROFISSIONAL</option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
/>
<div className="pt-1">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
<select
<SearchSelect
value={store.credenciada || ''}
onChange={(e) => store.setCredenciada(e.target.value)}
onChange={(v) => store.setCredenciada(v)}
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
>
<option value="">SELECIONE O CONVÊNIO</option>
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
/>
</div>
</div>
);
@@ -854,12 +991,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"
/>
<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">
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'}
{searchingPatients ? 'BUSCANDO...' : (patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...')}
</div>
) : (
filteredPatients.map(p => (
patientResults.map(p => (
<button
key={p.id}
onClick={() => {
+2 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen,
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen, FlaskConical,
ArrowRight, ArrowLeft, ChevronLeft, Loader2, Send, Check, CheckCircle2, User as UserIcon
} from 'lucide-react';
@@ -17,6 +17,7 @@ const SLIDES: { icon: any; accent: string; title: string; desc: string; cta: str
{ icon: Briefcase, accent: '#0d9488', title: 'Consultórios', desc: 'Para quem voa solo: agenda inteligente e cobrança sem complicação.', cta: 'Cadastrar meu consultório', pre: { perfil: 'consultorio' } },
{ icon: Stethoscope, accent: '#7c3aed', title: 'Dentistas', desc: 'Sua agenda única — sem overbooking, mesmo atendendo em vários lugares.', cta: 'Sou dentista', pre: { perfil: 'profissional', profissao: 'dentista' } },
{ icon: Sparkles, accent: '#db2777', title: 'Biomédicos', desc: 'Harmonização orofacial e estética com prontuário e fotos por procedimento.', cta: 'Sou biomédico(a)', pre: { perfil: 'profissional', profissao: 'biomedico' } },
{ icon: FlaskConical, accent: '#2d6a4f', title: 'Protéticos', desc: 'Laboratório completo: bancada, ordens, etapas, componentes, financeiro e score.', cta: 'Sou protético(a)', pre: { perfil: 'profissional', profissao: 'protetico' } },
{ icon: DoorOpen, accent: '#0d9488', title: 'Salas', desc: 'Alugue ou anuncie salas por hora, dia ou mês. Valor fixo ou negociado.', cta: 'Tenho salas para alugar', pre: { perfil: 'sala' } },
];
+283
View File
@@ -0,0 +1,283 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap, Briefcase, Wallet, ChevronLeft } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const dataHoraBR = (d: string) => { if (!d) return ''; const x = new Date(d); return isNaN(x.getTime()) ? dataBR(d) : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); };
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado' };
const STATUS_COR: Record<string, string> = { solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700', prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700', entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600' };
const FLUXO = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
const OCOR_CATS: Record<string, string> = { nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível', parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça incorreta', ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho', outro: 'Outro' };
const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> = ({ titulo, Icon, children }) => (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Icon size={12} /> {titulo}</p>
{children}
</div>
);
const PROD_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) : m; };
// Carteira do protético (via link): trabalhos + mini financeiro mensal (relação com a clínica).
const CarteiraView: React.FC<{ carteira: any }> = ({ carteira }) => {
if (!carteira) return <div className="flex justify-center py-16"><Loader2 className="animate-spin text-teal-600" size={28} /></div>;
const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => (
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-xl px-3 py-2 text-xs">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${STATUS_COR[t.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[t.status] || t.status}</span>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-700 truncate">{t.tipo}</p>
<p className="text-[10px] text-gray-400 truncate">{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}</p>
</div>
{Number(t.custo) > 0 && <span className="font-black text-gray-700 shrink-0">{fmtBRL(t.custo)}</span>}
</div>
);
return (
<main className="max-w-lg mx-auto p-3 space-y-3">
{carteira.clinica_nome && <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 text-[11px] font-black text-blue-700 uppercase flex items-center gap-1.5"><Building2 size={12} /> {carteira.clinica_nome}</div>}
{/* Mini financeiro */}
<div className="bg-[#2d6a4f] text-white rounded-2xl p-4">
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 flex items-center gap-1.5"><Wallet size={12} /> Recebido desta clínica</p>
<p className="text-2xl font-black mt-1">{fmtBRL(carteira.financeiro?.total_pago || 0)}</p>
<div className="mt-3 space-y-1">
{(carteira.financeiro?.por_mes || []).map((m: any) => (
<div key={m.mes} className="flex items-center justify-between text-xs bg-white/10 rounded-lg px-3 py-1.5">
<span className="font-bold capitalize">{mesBR(m.mes)}</span>
<span className="font-black">{fmtBRL(m.total)}</span>
</div>
))}
{(carteira.financeiro?.por_mes || []).length === 0 && <p className="text-[11px] opacity-70 uppercase font-bold">Nenhum pagamento registrado ainda.</p>}
</div>
</div>
<Secao titulo={`Em andamento (${carteira.andamento?.length || 0})`} Icon={Zap}>
<div className="space-y-1.5">
{(carteira.andamento || []).map((t: any) => <Linha key={t.id} t={t} />)}
{(carteira.andamento || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nada em produção.</p>}
</div>
</Secao>
<Secao titulo={`Finalizados (${carteira.finalizados?.length || 0})`} Icon={CheckCircle2}>
<div className="space-y-1.5">
{(carteira.finalizados || []).map((t: any) => <Linha key={t.id} t={t} fin />)}
{(carteira.finalizados || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum entregue ainda.</p>}
</div>
</Secao>
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">Valores recebidos do laboratório · sem dados do paciente</p>
</main>
);
};
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
const toast = useToast();
const [os, setOs] = useState<any>(null);
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [tela, setTela] = useState<'os' | 'carteira'>('os');
const [carteira, setCarteira] = useState<any>(null);
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
const abrirCarteira = () => { setTela('carteira'); if (!carteira) HybridBackend.getCarteiraPublic(token).then(setCarteira).catch(() => setCarteira({ andamento: [], finalizados: [], financeiro: { por_mes: [], total_pago: 0 } })); };
const act = async (fn: () => Promise<any>, ok: string) => {
setBusy(true);
try { await fn(); toast.success(ok); await recarregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const receber = () => act(() => HybridBackend.custodiaPublic(token, 'laboratorio'), 'RECEBIMENTO CONFIRMADO.');
const devolver = () => act(() => HybridBackend.custodiaPublic(token, 'clinica'), 'DEVOLUÇÃO REGISTRADA.');
const avancar = (s: string) => act(() => HybridBackend.statusPublic(token, s), 'ETAPA ATUALIZADA.');
const anexar = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; if (!f) return;
act(() => HybridBackend.uploadFotoPublic(token, f), 'FOTO ANEXADA.'); e.target.value = '';
};
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="animate-spin text-teal-600" size={32} /></div>;
if (erro || !os) return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
<AlertTriangle size={40} className="text-amber-400" />
<p className="mt-3 font-black text-gray-700 uppercase text-sm">{erro || 'Link inválido'}</p>
<p className="text-xs text-gray-400 mt-1">Peça um novo link à clínica.</p>
</div>
);
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
const idxAtual = FLUXO.indexOf(os.status);
const cancelado = os.status === 'cancelado';
return (
<div className="min-h-screen bg-gray-50">
{/* Topo / marca */}
<header className="bg-[#2d6a4f] text-white px-4 py-3 flex items-center gap-2 sticky top-0 z-10">
<FlaskConical size={18} />
<div className="min-w-0 flex-1">
<p className="font-black text-sm leading-tight">ScoreOdonto</p>
<p className="text-[10px] text-white/70 uppercase tracking-wide leading-tight">Acompanhamento de prótese</p>
</div>
{tela === 'os'
? <button onClick={abrirCarteira} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><Briefcase size={11} /> Meus trabalhos</button>
: <button onClick={() => setTela('os')} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><ChevronLeft size={11} /> Voltar à OS</button>}
</header>
{tela === 'carteira' && <CarteiraView carteira={carteira} />}
{tela === 'os' && <main className="max-w-lg mx-auto p-3 space-y-3">
{/* Cabeçalho da OS */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1 className="font-black text-gray-800 text-base leading-tight">{os.tipo}</h1>
<p className="text-xs text-gray-400 mt-0.5">OS {os.id}</p>
</div>
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase shrink-0 ${STATUS_COR[os.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[os.status] || os.status}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{os.clinica_nome && <span className="text-[10px] font-black text-blue-700 bg-blue-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><Building2 size={11} /> {os.clinica_nome}</span>}
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase flex items-center gap-1 ${noLab ? 'bg-violet-50 text-violet-700' : 'bg-blue-50 text-blue-700'}`}><MapPin size={11} /> {noLab ? 'Com o laboratório' : 'Na clínica'}</span>
{os.tipo_os === 'garantia' && <span className="text-[10px] font-black text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><ShieldCheck size={11} /> Garantia</span>}
</div>
</div>
{/* Ações do protético (sem conta) */}
{!os.finalizada && (
<Secao titulo="Atualizar status" Icon={Zap}>
<div className="space-y-2">
{noLab
? <button disabled={busy} onClick={devolver} className="w-full py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-50">Devolvi à clínica</button>
: <button disabled={busy} onClick={receber} className="w-full py-2.5 rounded-xl bg-[#2d6a4f] text-white font-black text-xs uppercase disabled:opacity-50">Recebi o trabalho</button>}
<div>
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Etapa de produção</p>
<div className="flex flex-wrap gap-1.5">
{PROD_FLUXO.map(s => (
<button key={s} disabled={busy || os.status === s} onClick={() => avancar(s)}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-[#2d6a4f] text-white' : 'border border-gray-200 text-gray-500'}`}>{STATUS_LABEL[s]}</button>
))}
</div>
</div>
<label className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
<ImagePlus size={15} /> Anexar foto do trabalho
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</div>
</Secao>
)}
{/* Etapas */}
<Secao titulo="Etapas" Icon={Clock}>
{cancelado ? (
<div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase">OS cancelada</div>
) : (
<div className="flex items-start overflow-x-auto pb-1 -mx-1 px-1">
{FLUXO.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
const ev = (os.eventos || []).find((e: any) => e.status === s);
return (
<React.Fragment key={s}>
<div className="flex flex-col items-center min-w-[52px]">
{done ? <CheckCircle2 size={18} className="text-teal-600" /> : atual ? <div className="w-4 h-4 rounded-full bg-[#2d6a4f] ring-4 ring-green-100" /> : <Circle size={18} className="text-gray-200" />}
<span className={`text-[7px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-[#2d6a4f]' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[7px] text-gray-300">{ev ? dataBR(ev.created_at) : ''}</span>
</div>
{i < FLUXO.length - 1 && <div className={`flex-1 h-0.5 mt-2 min-w-[8px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
</React.Fragment>
);
})}
</div>
)}
</Secao>
{/* Dados */}
<Secao titulo="Dados do trabalho" Icon={FlaskConical}>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
{os.paciente_nome && <div className="col-span-2"><span className="text-gray-400 font-bold uppercase text-[9px] block">Paciente</span>{os.paciente_nome}</div>}
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
{os.dentes && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentes</span>{os.dentes}</div>}
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
{os.prazo_entrega && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Prazo</span>{dataBR(os.prazo_entrega)}</div>}
{Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor do lab</span>{fmtBRL(os.custo)}</div>}
</div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3 mt-3">{os.observacoes}</p>}
{os.garantia_ate && <div className="bg-emerald-50 rounded-xl px-3 py-2 mt-3 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)}</div>}
</Secao>
{/* Componentes */}
{(os.componentes || []).length > 0 && (
<Secao titulo="Componentes" Icon={Package}>
<div className="space-y-1.5">
{os.componentes.map((c: any) => (
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
<span className="font-black text-gray-700">{c.quantidade}×</span>
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
</div>
))}
</div>
</Secao>
)}
{/* Fotos */}
{(os.fotos || []).length > 0 && (
<Secao titulo="Fotos do trabalho" Icon={Camera}>
<div className="grid grid-cols-2 gap-2">
{os.fotos.map((f: any) => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="block rounded-lg overflow-hidden border border-gray-100">
<img src={f.url} alt="" className="w-full h-24 object-cover" />
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate px-2 py-1">{f.legenda}</p>}
</a>
))}
</div>
</Secao>
)}
{/* Ocorrências */}
{(os.ocorrencias || []).length > 0 && (
<Secao titulo="Ocorrências" Icon={AlertTriangle}>
<div className="space-y-2">
{os.ocorrencias.map((o: any) => (
<div key={o.id} className="border border-gray-100 rounded-xl p-2.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-[8px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-100 text-gray-600">{(o.status || '').replace('_', ' ')}</span>
<span className="font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
</div>
<p className="text-gray-600 mt-1">{o.descricao}</p>
{o.resposta && <p className="text-gray-500 mt-1"><b className="uppercase text-[9px]">Resposta:</b> {o.resposta}</p>}
</div>
))}
</div>
</Secao>
)}
{/* Histórico */}
<Secao titulo="Histórico" Icon={History}>
<div className="space-y-2">
{(os.eventos || []).map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs">
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : 'bg-[#2d6a4f]'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
<span className="text-gray-400 block text-[10px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
</div>
</div>
))}
</div>
</Secao>
{/* CTA de conversão */}
<a href="/" className="block bg-[#2d6a4f] text-white rounded-2xl p-4 text-center active:scale-[0.99] transition-transform">
<p className="font-black text-sm uppercase">Gostou de acompanhar por aqui?</p>
<p className="text-[11px] text-white/80 mt-1">Crie sua conta grátis e gerencie seu laboratório: bancada, etapas, financeiro e score.</p>
<span className="inline-flex items-center gap-1 mt-2 bg-white text-[#2d6a4f] font-black text-xs uppercase px-4 py-2 rounded-xl">Criar conta grátis <ChevronRight size={14} /></span>
</a>
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">ScoreOdonto · Provedor de Prótese</p>
</main>}
</div>
);
};
File diff suppressed because it is too large Load Diff
+102 -14
View File
@@ -1,5 +1,5 @@
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 { useToast } from '../contexts/ToastContext.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 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 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');
@@ -26,12 +26,17 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
if (!sameDay(ini, dia)) return null;
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 manut = r.tipo === 'manutencao';
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 (
<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'}`}
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' })}`}>
<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={`${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-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>
);
});
@@ -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">
<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">
{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>
<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>
@@ -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">
<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-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>
);
@@ -92,9 +159,12 @@ export const SalaHomeView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
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 [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
const [loading, setLoading] = useState(false);
const [bloqueioOpen, setBloqueioOpen] = useState(false);
// Relatório por sala (período)
const hoje = new Date();
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 () => {
setLoading(true);
try {
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
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()]);
setLancamentos(Array.isArray(fin) ? fin : []);
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
}
} catch { /* silent */ } finally { setLoading(false); }
}, []);
}, [isOperador, ws?.id]);
useEffect(() => { carregar(); }, [carregar]);
const carregarRel = useCallback(() => {
if (isOperador) { setRel(null); return; }
setRelLoading(true);
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
}, [inicio, fim]);
}, [inicio, fim, isOperador]);
useEffect(() => { carregarRel(); }, [carregarRel]);
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 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) => {
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
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> : (
<>
<div className="grid 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" />
<KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />
<div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
{!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
{!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 confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
</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="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>
@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
</div>
)}
</div>
</>)}
</>
)}
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
</div>
);
};
+153 -3
View File
@@ -2,8 +2,150 @@ import React, { useState, useEffect, useCallback } from 'react';
import {
UserSearch, Stethoscope, Wrench, Sparkles, MapPin, Search, Mail, Phone, RefreshCw,
Save, BadgeCheck, Inbox, Send, CheckCircle2, XCircle, CircleDollarSign, UserCircle2, X,
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2, Tags, ChevronDown
} from 'lucide-react';
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
const ws = HybridBackend.getActiveWorkspace();
const [produtos, setProdutos] = useState<any[]>([]);
const [dentistas, setDentistas] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [pacientes, setPacientes] = useState<any[]>([]);
const [paciente, setPaciente] = useState<any>(null);
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
const [saving, setSaving] = useState(false);
const [okMsg, setOkMsg] = useState(false);
useEffect(() => {
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
}, [prof.id, ws?.id]);
useEffect(() => {
if (paciente) return;
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
return () => clearTimeout(t);
}, [busca, paciente]);
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
const salvar = async () => {
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
const dent = dentistas.find(d => d.id === form.dentista_id);
setSaving(true);
try {
await HybridBackend.createProteseOS({
...form, protetico_id: prof.id, valor: 0,
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
});
setOkMsg(true);
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
};
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
{okMsg ? (
<div className="p-8 text-center space-y-2">
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
</div>
) : (
<div className="p-5 space-y-4">
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
{produtos.length > 0 ? (
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value="">Escolha o trabalho</option>
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
</select>
) : (
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
)}
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
{paciente ? (
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
) : (
<div className="relative mt-1">
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
{busca && pacientes.length > 0 && (
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
<option value=""></option>
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
</select>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
</div>
</div>
<div>
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
</div>
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
</div>
)}
</div>
</div>
);
};
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
const [aberto, setAberto] = useState(false);
const [produtos, setProdutos] = useState<any[] | null>(null);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (!aberto && produtos === null) {
setLoading(true);
try { const p = await HybridBackend.getLaboratorioProdutos(proteticoId); setProdutos(Array.isArray(p) ? p : []); }
catch { setProdutos([]); } finally { setLoading(false); }
}
setAberto(a => !a);
};
return (
<div className="mb-2">
<button onClick={toggle} className="w-full py-2 rounded-xl border border-gray-200 text-gray-600 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 hover:bg-gray-50">
<Tags size={12} /> {aberto ? 'Ocultar catálogo' : 'Ver catálogo de próteses'} {aberto ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{aberto && (
<div className="mt-2 space-y-1">
{loading ? <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Carregando</p>
: produtos && produtos.length ? produtos.map(pr => (
<div key={pr.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-1.5">
<span className="font-bold text-gray-700 flex-1 truncate">{pr.nome}</span>
{pr.garantia_meses ? <span className="text-[9px] text-emerald-600 font-black uppercase">{pr.garantia_meses}m gar.</span> : null}
<span className="font-black text-gray-800">{fmtBRLp(pr.preco)}</span>
</div>
)) : <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Sem catálogo publicado.</p>}
</div>
)}
</div>
);
};
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.tsx';
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
@@ -44,6 +186,9 @@ interface Profissional {
raio_atuacao_km?: number | null;
bio_profissional?: string | null;
dist_km?: number | string | null;
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
verificado?: boolean; // selo (≥ 5 entregas)
avaliacoes?: number; // nº de avaliações da clínica
}
interface Proposta {
@@ -406,6 +551,7 @@ export const ProfissionaisPlugin: React.FC = () => {
// propostas
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
@@ -699,6 +845,8 @@ export const ProfissionaisPlugin: React.FC = () => {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto"> {p.nota}{p.avaliacoes ? ` · ${p.avaliacoes}` : ''}</span>}
{p.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5" title="Laboratório verificado"><BadgeCheck size={10} /> Verificado</span>}
</div>
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
</div>
@@ -721,10 +869,11 @@ export const ProfissionaisPlugin: React.FC = () => {
</div>
)}
</div>
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
{ws?.id && (
<button onClick={() => setProposingTo(p)}
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
<Send size={13} /> ENVIAR PROPOSTA
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
</button>
)}
</div>
@@ -893,6 +1042,7 @@ export const ProfissionaisPlugin: React.FC = () => {
{tab === 'modelos' && <ModelosManager />}
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
</div>
);
};