Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d357f3a2e8 | |||
| 2e90a2ba5f | |||
| 53cf2c52e6 | |||
| 8040e70310 | |||
| 8552e87665 | |||
| 28dc27a289 | |||
| f0b7a45d86 | |||
| 672c3b01e5 | |||
| 5f4460e37d | |||
| e05231663f | |||
| 84f4a797d2 | |||
| ae48feaa8a | |||
| c8d679eb70 | |||
| 3ffaff9d7d | |||
| 7ae6993c4b | |||
| 9f103be397 | |||
| f4f433b14c | |||
| 8cf4a83161 | |||
| 623c9990d6 | |||
| b80d061a6f | |||
| 064f36d614 | |||
| 50e1483ad5 | |||
| a1c203ced5 | |||
| 9ed8bc25bd | |||
| 8676308325 | |||
| 983582f066 | |||
| 718a7886e1 | |||
| 9b0ba65eca | |||
| 553efb003b | |||
| 321ca7e127 | |||
| d005fe77a9 | |||
| 0ade8027f5 | |||
| 8f9177a1c1 |
+891
-25
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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).**
|
||||
|
||||
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
|
||||
> camada de token público.
|
||||
@@ -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
@@ -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.25 · commit `8040e70`**.
|
||||
- **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.25 · `8040e70` · 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.25**), 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+33
-1
@@ -31,6 +31,10 @@ 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 { LabClientesView } from './views/LabClientesView.tsx';
|
||||
import { LabEquipeView } from './views/LabEquipeView.tsx';
|
||||
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
import { OrcamentosView } from './views/OrcamentosView.tsx';
|
||||
import { ContratosView } from './views/ContratosView.tsx';
|
||||
@@ -84,6 +88,12 @@ export type ViewKey =
|
||||
| 'minhas-salas'
|
||||
| 'alugar-salas'
|
||||
| 'sala-home'
|
||||
| 'lab-home'
|
||||
| 'lab-bancada'
|
||||
| 'lab-clientes'
|
||||
| 'lab-financeiro'
|
||||
| 'lab-equipe'
|
||||
| 'lab-indicadores'
|
||||
| 'marketplace-profissionais'
|
||||
| 'criar-clinica'
|
||||
| 'rx-config'
|
||||
@@ -134,6 +144,12 @@ 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-clientes': 'lab-clientes',
|
||||
'/lab-financeiro': 'lab-financeiro',
|
||||
'/lab-equipe': 'lab-equipe',
|
||||
'/lab-indicadores': 'lab-indicadores',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
'/criar-clinica': 'criar-clinica',
|
||||
'/rx-config': 'rx-config',
|
||||
@@ -189,6 +205,12 @@ 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-clientes': '/lab-clientes',
|
||||
'lab-financeiro': '/lab-financeiro',
|
||||
'lab-equipe': '/lab-equipe',
|
||||
'lab-indicadores': '/lab-indicadores',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
'criar-clinica': '/criar-clinica',
|
||||
'rx-config': '/rx-config',
|
||||
@@ -287,6 +309,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-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
|
||||
if (view === 'plugins') return false;
|
||||
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
|
||||
@@ -305,7 +329,7 @@ const App: React.FC = () => {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
@@ -316,6 +340,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 +502,12 @@ 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-clientes': return <LabClientesView tab="clientes" />;
|
||||
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
|
||||
case 'lab-equipe': return <LabEquipeView />;
|
||||
case 'lab-indicadores': return <LabIndicadoresView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
|
||||
case 'rx-config': return <RxConfigView />;
|
||||
|
||||
@@ -37,9 +37,12 @@ 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' });
|
||||
}
|
||||
@@ -58,6 +61,12 @@ 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-clientes', label: 'CLIENTES', icon: Building2 },
|
||||
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
|
||||
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
@@ -94,11 +103,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-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
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-clientes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
}
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
|
||||
|
||||
@@ -128,7 +149,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
@@ -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>
|
||||
|
||||
@@ -778,13 +778,88 @@ export const HybridBackend = {
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
uploadProteseFoto: async (id: string, file: File) => {
|
||||
uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
if (legenda) fd.append('legenda', legenda);
|
||||
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
|
||||
return await res.json();
|
||||
},
|
||||
// Conferência de componente (status: ok | divergente | ausente | pendente)
|
||||
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
|
||||
return await res.json();
|
||||
},
|
||||
// Ocorrências (contraditório)
|
||||
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
|
||||
return await res.json();
|
||||
},
|
||||
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
|
||||
return await res.json();
|
||||
},
|
||||
statusProteseOcorrencia: async (ocId: string, status: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseFoto: async (fotoId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
|
||||
return await res.json();
|
||||
},
|
||||
// Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
|
||||
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseComponente: async (compId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
|
||||
return await res.json();
|
||||
},
|
||||
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
|
||||
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProtesePagamento: async (pagId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
|
||||
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
|
||||
return await res.json();
|
||||
},
|
||||
// Editar campos do trabalho (cor, material, dentes, observações)
|
||||
editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar');
|
||||
return await res.json();
|
||||
},
|
||||
// Ajuste do valor cobrado do paciente (delta + ou −)
|
||||
ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor');
|
||||
return await res.json();
|
||||
},
|
||||
// Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo
|
||||
trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseOS: async (id: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
@@ -1011,6 +1086,101 @@ export const HybridBackend = {
|
||||
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
|
||||
},
|
||||
|
||||
// Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos.
|
||||
getLabClientes: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/clientes`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getLabPagamentos: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
||||
getLabDados: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar');
|
||||
return await res.json();
|
||||
},
|
||||
getLabEquipe: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
addLabTecnico: async (body: { email: string; nome?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico');
|
||||
return await res.json();
|
||||
},
|
||||
removeLabTecnico: async (userId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover');
|
||||
return await res.json();
|
||||
},
|
||||
// Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados)
|
||||
getLabIndicadores: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
// 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 }));
|
||||
},
|
||||
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();
|
||||
},
|
||||
// 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();
|
||||
},
|
||||
// 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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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] },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
|
||||
// Área do Laboratório (Fase 3): Clientes (clínicas que enviam OS) + Financeiro (a-receber).
|
||||
// Uma view, duas abas — o item de menu define a aba inicial.
|
||||
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
|
||||
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [pagamentos, setPagamentos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setAba(tab); }, [tab]);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, p] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos()]);
|
||||
setClientes(Array.isArray(c) ? c : []);
|
||||
setPagamentos(Array.isArray(p) ? p : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
|
||||
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
|
||||
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex border-b border-gray-100">
|
||||
{(['clientes', 'financeiro'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes' : 'Financeiro'}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
{aba === 'clientes' && (
|
||||
clientes.length === 0 ? (
|
||||
<div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Clínica', 'OS', 'Ativas', 'Atrasadas', 'Entregues', 'Faturado', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{clientes.map(c => (
|
||||
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase flex items-center gap-2"><Building2 size={14} className="text-teal-600 shrink-0" /> {c.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
|
||||
<td className="px-4 py-3 text-teal-600 font-bold">{c.ativas}</td>
|
||||
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-gray-400'}`}>{c.atrasadas}</td>
|
||||
<td className="px-4 py-3 text-green-600 font-bold">{c.entregues}</td>
|
||||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(c.faturado)}</td>
|
||||
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{aba === 'financeiro' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
|
||||
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
|
||||
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
<Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length} lançamento(s)</span>
|
||||
</div>
|
||||
{pagamentos.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento registrado.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Clínica', 'Paciente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagamentos.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{p.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
|
||||
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Faturado = custo das OS entregues (exceto garantia). A receber = faturado − recebido.</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos.
|
||||
export const LabEquipeView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [dados, setDados] = useState<any>(null);
|
||||
const [equipe, setEquipe] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [nome, setNome] = useState('');
|
||||
const [cred, setCred] = useState<{ email: string; senha: string } | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]);
|
||||
setDados(d || {});
|
||||
setEquipe(Array.isArray(e) ? e : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const salvarDados = async () => {
|
||||
if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const addTecnico = async () => {
|
||||
if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined });
|
||||
toast.success('TÉCNICO ADICIONADO.');
|
||||
if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword });
|
||||
setEmail(''); setNome(''); carregar();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const remover = async (u: any) => {
|
||||
if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return;
|
||||
try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
|
||||
if (loading || !dados) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
const ehPJ = dados.pessoa_tipo === 'PJ';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="LABORATÓRIO & EQUIPE">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Dados do provedor */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['PF', 'PJ'] as const).map(t => (
|
||||
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
|
||||
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
|
||||
<input value={dados.nome_fantasia || ''} onChange={e => setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
|
||||
<input value={dados.documento || ''} onChange={e => setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
|
||||
</div>
|
||||
|
||||
{/* Equipe */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção — sem acesso a clientes, financeiro ou valores do paciente.</p>
|
||||
|
||||
{cred && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
|
||||
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
|
||||
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
|
||||
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{equipe.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
|
||||
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
|
||||
</div>
|
||||
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
|
||||
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
|
||||
export const LabHomeView: React.FC = () => {
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
const [os, setOs] = useState<any[]>([]);
|
||||
const [fin, setFin] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getFinanceiro()]);
|
||||
setOs(Array.isArray(b) ? b : []);
|
||||
setFin(Array.isArray(f) ? f : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
|
||||
const atrasadas = os.filter(o => o.atrasada).length;
|
||||
const ymMes = new Date().toISOString().slice(0, 7);
|
||||
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
|
||||
// Faturamento do mês: RECEITA de prótese lançada no workspace do lab (espelho da entrega).
|
||||
const fatMes = fin
|
||||
.filter(l => l.origem === 'protese' && (l.data_competencia || l.datavencimento || '').slice(0, 7) === ymMes)
|
||||
.reduce((s, l) => s + Number(l.valor || 0), 0);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Distribuição por status (fila de produção).
|
||||
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
|
||||
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="PAINEL DO LABORATÓRIO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
|
||||
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
|
||||
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
|
||||
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
|
||||
</div>
|
||||
|
||||
{/* Fila por status */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
<Clock size={16} className="text-teal-600" />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
|
||||
</div>
|
||||
{porStatus.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
|
||||
) : (
|
||||
<div className="p-4 flex flex-wrap gap-3">
|
||||
{porStatus.map(({ s, n }) => (
|
||||
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
|
||||
<p className="text-2xl font-black text-gray-800">{n}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Próximas entregas */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
|
||||
</div>
|
||||
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
|
||||
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
|
||||
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
|
||||
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
|
||||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto.
|
||||
export const LabIndicadoresView: React.FC = () => {
|
||||
const [ind, setInd] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
|
||||
const temNota = ind.nota !== null && ind.nota !== undefined;
|
||||
const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="INDICADORES DO LABORATÓRIO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Nota ScoreOdonto */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
|
||||
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
|
||||
<Award size={18} />
|
||||
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
||||
{temNota ? (
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Calculada sobre {ind.entregues} entrega(s). Penaliza atraso, retrabalho e ocorrências resolvidas.</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação — mínimo de {ind.min_os} entregas para gerar a nota.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
|
||||
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
|
||||
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
|
||||
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
|
||||
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
|
||||
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
|
||||
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). Só ocorrências <b>resolvidas</b> (após contraditório) entram — abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+185
-27
@@ -32,6 +32,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 +122,14 @@ 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 [laboratorios, setLaboratorios] = useState<any[]>([]);
|
||||
const [selectedLab, setSelectedLab] = useState<string>('');
|
||||
const [labProdutos, setLabProdutos] = useState<any[]>([]);
|
||||
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
|
||||
const [protValor, setProtValor] = useState<string>('');
|
||||
const [protObs, setProtObs] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const p = await HybridBackend.getPacientes();
|
||||
@@ -140,14 +151,55 @@ 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]
|
||||
);
|
||||
|
||||
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
|
||||
useEffect(() => {
|
||||
if (!isProtese || laboratorios.length) return;
|
||||
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
}, [isProtese, laboratorios.length]);
|
||||
|
||||
// Catálogo do laboratório selecionado.
|
||||
useEffect(() => {
|
||||
if (!selectedLab) { setLabProdutos([]); return; }
|
||||
let active = true;
|
||||
HybridBackend.getLaboratorioProdutos(selectedLab)
|
||||
.then(p => { if (active) setLabProdutos(Array.isArray(p) ? p : []); })
|
||||
.catch(() => { if (active) setLabProdutos([]); });
|
||||
return () => { active = false; };
|
||||
}, [selectedLab]);
|
||||
|
||||
// Busca de pacientes é SERVER-SIDE: a clínica pode ter milhares e a rota sem termo
|
||||
// devolve só as 50 primeiras. Filtrar essas 50 no cliente fazia a busca "não achar"
|
||||
// quem estava fora da 1ª página. Sem termo, mostra a lista inicial (recentes).
|
||||
const [patientResults, setPatientResults] = useState<Paciente[]>([]);
|
||||
const [searchingPatients, setSearchingPatients] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const term = patientSearchTerm.trim();
|
||||
if (!term) { setPatientResults(pacientes.slice(0, 30)); setSearchingPatients(false); return; }
|
||||
let active = true;
|
||||
setSearchingPatients(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const r: any = await HybridBackend.getPacientes(term);
|
||||
const list: Paciente[] = Array.isArray(r) ? r : (r?.data ?? []);
|
||||
if (active) setPatientResults(list);
|
||||
} finally {
|
||||
if (active) setSearchingPatients(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { active = false; clearTimeout(t); };
|
||||
}, [patientSearchTerm, pacientes]);
|
||||
|
||||
useEffect(() => {
|
||||
const { pendingSearch } = store;
|
||||
@@ -250,6 +302,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 +353,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
|
||||
@@ -390,6 +470,9 @@ export const LancarGTO: React.FC = () => {
|
||||
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"
|
||||
>
|
||||
@@ -397,24 +480,99 @@ export const LancarGTO: React.FC = () => {
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedEsp && (
|
||||
<div className="space-y-2">
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
value={selectedLab}
|
||||
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={selectedProdutoId}
|
||||
onChange={(e) => {
|
||||
setSelectedProdutoId(e.target.value);
|
||||
const pr = labProdutos.find(p => p.id === e.target.value);
|
||||
setProtValor(pr ? String(pr.preco ?? '') : '');
|
||||
}}
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A PRÓTESE...</option>
|
||||
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} — R$ {Number(p.preco || 0).toFixed(2)}</option>)}
|
||||
</select>
|
||||
|
||||
{selectedProduto && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={protValor}
|
||||
onChange={e => setProtValor(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-400 outline-none transition-all"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={protObs}
|
||||
onChange={e => setProtObs(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="OBSERVAÇÃO PARA O PROTÉTICO (cor, material, dentes...)"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] focus:border-amber-400 outline-none transition-all resize-none"
|
||||
/>
|
||||
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
|
||||
<ChevronRight size={10} /> AO FINALIZAR A GTO, UMA OS DE PRÓTESE SERÁ ABERTA NA BANCADA DO LABORATÓRIO.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddProtese}
|
||||
className="w-full bg-[#2d6a4f] text-white py-4 rounded-2xl font-black text-sm uppercase shadow-lg shadow-green-100 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
ADICIONAR PRÓTESE <ChevronRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEsp && !isProtese && (
|
||||
<div className="space-y-2">
|
||||
{filteredProcedimentos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Nenhum procedimento cadastrado nesta especialidade</p>
|
||||
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
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>
|
||||
)}
|
||||
|
||||
{selectedProc && (
|
||||
<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">
|
||||
@@ -854,12 +1012,12 @@ export const LancarGTO: React.FC = () => {
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase"
|
||||
/>
|
||||
<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={() => {
|
||||
|
||||
@@ -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' } },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap } 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'];
|
||||
|
||||
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 recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
|
||||
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
|
||||
|
||||
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>
|
||||
<span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-white/15 uppercase">{os.finalizada ? 'Somente leitura' : 'Acompanhamento'}</span>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
+587
-85
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check } from 'lucide-react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -7,6 +7,11 @@ import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
const dataHoraBR = (d: string) => {
|
||||
if (!d) return '';
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? dataBR(d) : dt.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
|
||||
@@ -17,7 +22,6 @@ const STATUS_COR: Record<string, string> = {
|
||||
prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700',
|
||||
entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600',
|
||||
};
|
||||
const LAB_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
|
||||
const Badge: React.FC<{ s: string }> = ({ s }) => (
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_COR[s] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[s] || s}</span>
|
||||
@@ -117,7 +121,7 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
|
||||
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}</option>)}
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
{labs.length === 0 && <p className="text-[10px] text-gray-400 mt-1">Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.</p>}
|
||||
</div>
|
||||
@@ -176,6 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
const confirm = useConfirm();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas');
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
||||
@@ -190,14 +195,6 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(id, file); toast.success('FOTO ANEXADA.'); carregar(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acionarGarantia = async () => {
|
||||
if (!(await confirm('ABRIR UMA OS DE GARANTIA (SEM CUSTO) PARA REFAZER ESTE TRABALHO?'))) return;
|
||||
setBusy(true);
|
||||
@@ -216,100 +213,601 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const moverCustodia = async (para: 'clinica' | 'laboratorio') => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.moverProteseCustodia(id, para); toast.success(para === 'laboratorio' ? 'TRABALHO COM O LABORATÓRIO.' : 'TRABALHO NA CLÍNICA.'); carregar(); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!os) return null;
|
||||
const finalizado = os.status === 'entregue' || os.status === 'cancelado';
|
||||
const naGarantia = os.status === 'entregue' && os.garantia_ate && os.garantia_ate >= new Date().toISOString().slice(0, 10);
|
||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||
|
||||
// Recibo imprimível de uma etapa (comprovante que circula com o trabalho).
|
||||
// formato 'termica' (bobina 80mm) | 'laser' (folha A4). Dados preenchidos da OS.
|
||||
const imprimirCupom = (etapa: string, formato: 'termica' | 'laser') => {
|
||||
const comps = (os.componentes || []).map((c: any) => `${c.quantidade}× ${c.nome}`).join(', ') || '—';
|
||||
const termica = formato === 'termica';
|
||||
const page = termica ? '@page{size:80mm auto;margin:3mm}body{width:72mm;font-size:11px}'
|
||||
: '@page{size:A4;margin:16mm}body{max-width:520px;font-size:12px}';
|
||||
const sign = termica
|
||||
? '.sign{margin-top:24px}.sign div{border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555;margin-top:18px}'
|
||||
: '.sign{margin-top:34px;display:flex;justify-content:space-between;gap:16px}.sign div{flex:1;border-top:1px solid #111;padding-top:4px;text-align:center;font-size:9px;text-transform:uppercase;color:#555}';
|
||||
const w = window.open('', '_blank', termica ? 'width=320,height=640' : 'width=560,height=720'); if (!w) return;
|
||||
w.document.write(`<html><head><title>Recibo ${STATUS_LABEL[etapa] || etapa}</title><style>
|
||||
*{font-family:Arial,Helvetica,sans-serif;color:#111;box-sizing:border-box}body{margin:0;padding:10px}
|
||||
h1{font-size:${termica ? 13 : 16}px;margin:0 0 2px}.muted{color:#555;font-size:${termica ? 9 : 10}px}
|
||||
.et{background:#111;color:#fff;padding:6px 8px;border-radius:5px;font-weight:bold;text-align:center;text-transform:uppercase;margin:10px 0;font-size:${termica ? 11 : 13}px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:6px}td{padding:3px 0;vertical-align:top}
|
||||
.lbl{color:#555;text-transform:uppercase;font-size:9px;width:40%}
|
||||
.hr{border-top:1px dashed #999;margin:9px 0}${sign}${page}</style></head><body>
|
||||
<h1>ORDEM DE PRÓTESE</h1><div class="muted">OS ${os.id}<br>${new Date().toLocaleString('pt-BR')}</div>
|
||||
<div class="et">Etapa: ${STATUS_LABEL[etapa] || etapa}</div>
|
||||
<table>
|
||||
<tr><td class="lbl">Paciente</td><td><b>${os.paciente_nome || '—'}</b></td></tr>
|
||||
<tr><td class="lbl">Trabalho</td><td>${os.tipo || '—'}</td></tr>
|
||||
<tr><td class="lbl">Dentista</td><td>${os.dentista_nome || '—'}</td></tr>
|
||||
<tr><td class="lbl">Laboratório</td><td>${os.protetico_nome || '—'}</td></tr>
|
||||
<tr><td class="lbl">Dentes</td><td>${os.dentes || '—'}</td></tr>
|
||||
<tr><td class="lbl">Material / Cor</td><td>${os.material || '—'} / ${os.cor || '—'}</td></tr>
|
||||
<tr><td class="lbl">Componentes</td><td>${comps}</td></tr>
|
||||
<tr><td class="lbl">Prazo</td><td>${os.prazo_entrega ? dataBR(os.prazo_entrega) : '—'}</td></tr>
|
||||
<tr><td class="lbl">Local atual</td><td>${noLab ? 'Laboratório' : 'Clínica'}</td></tr>
|
||||
</table><div class="hr"></div>
|
||||
${os.observacoes ? `<div class="muted">Obs.: ${os.observacoes}</div>` : ''}
|
||||
<div class="sign"><div>Entregue por</div><div>Recebido por</div></div>
|
||||
</body></html>`);
|
||||
w.document.close(); w.focus(); setTimeout(() => w.print(), 250);
|
||||
};
|
||||
|
||||
const idxAtual = FLUXO_OS.indexOf(os.status);
|
||||
|
||||
const TABS = [
|
||||
{ id: 'etapas', label: 'Etapas' },
|
||||
{ id: 'itens', label: 'Itens & Valores' },
|
||||
{ id: 'monitor', label: 'Monitoramento' },
|
||||
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
|
||||
{ id: 'timeline', label: 'Linha do tempo' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<div>
|
||||
<div className="bg-white rounded-2xl w-full max-w-2xl h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge s={os.status} />
|
||||
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
|
||||
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> {dataBR(os.prazo_entrega)}</span>}
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
{os.garantia_ate && (
|
||||
<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold">
|
||||
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
|
||||
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
|
||||
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor</span>{fmtBRL(os.valor)}</div>}
|
||||
</div>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
|
||||
</div>
|
||||
{/* Abas */}
|
||||
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Fotos */}
|
||||
{os.fotos?.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{os.fotos.map((f: any) => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-100" /></a>
|
||||
))}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{tab === 'etapas' && (
|
||||
<div className="space-y-5">
|
||||
{/* Clínica de origem — crítico no multi-clínica (de qual clínica veio a OS). */}
|
||||
{modo === 'bancada' && os.clinica_nome && (
|
||||
<div className="bg-blue-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs font-black text-blue-700 uppercase"><Building2 size={14} /> Clínica: {os.clinica_nome}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
|
||||
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
|
||||
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> Prazo {dataBR(os.prazo_entrega)}</span>}
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${noLab ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</span>
|
||||
</div>
|
||||
<EtapasStepper os={os} />
|
||||
{os.garantia_ate && (<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)</div>)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-xs">
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
|
||||
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
|
||||
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor cobrado</span>{fmtBRL(os.valor)}</div>}
|
||||
</div>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
|
||||
{/* Etapas do trabalho: avançar + imprimir recibo (térmica/laser) por etapa */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Etapas do trabalho</p>
|
||||
<div className="space-y-1.5">
|
||||
{FLUXO_OS.map((s, i) => {
|
||||
const done = i < idxAtual, atual = i === idxAtual;
|
||||
const ev = (os.eventos || []).find((e: any) => e.status === s);
|
||||
// Etapas livres: pode ir para qualquer uma (pular/repetir/voltar), exceto 'entregue' (timeline).
|
||||
const acaoLabel = atual ? 'Repetir' : done ? 'Voltar' : 'Ir';
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-2 bg-gray-50 rounded-xl px-3 py-2">
|
||||
{done ? <CheckCircle2 size={16} className="text-teal-600 shrink-0" /> : atual ? <div className="w-4 h-4 rounded-full bg-violet-600 shrink-0" /> : <Circle size={16} className="text-gray-300 shrink-0" />}
|
||||
<span className={`text-xs font-black uppercase ${atual ? 'text-violet-700' : done ? 'text-gray-700' : 'text-gray-400'}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[10px] text-gray-400">{ev ? dataBR(ev.created_at) : ''}</span>
|
||||
<div className="flex-1" />
|
||||
{!finalizado && s !== 'entregue' && <button disabled={busy} onClick={() => mudar(s)} className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase disabled:opacity-50 ${atual ? 'border border-violet-200 text-violet-700 hover:bg-violet-50' : 'bg-violet-600 text-white'}`}>{acaoLabel}</button>}
|
||||
{!finalizado && s === 'entregue' && <span className="text-[9px] text-amber-500 font-black uppercase">aba Linha do tempo</span>}
|
||||
<button onClick={() => imprimirCupom(s, 'termica')} title="Imprimir em impressora térmica (bobina 80mm)" className="p-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white"><Printer size={13} /></button>
|
||||
<button onClick={() => imprimirCupom(s, 'laser')} title="Imprimir em impressora laser (folha A4)" className="px-2 py-1.5 rounded-lg border border-gray-200 text-gray-500 hover:bg-white text-[9px] font-black uppercase">A4</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-300 mt-1.5 uppercase font-bold flex items-center gap-1"><Printer size={10} /> = térmica 80mm · A4 = laser · etapas podem ser repetidas/puladas</p>
|
||||
</div>
|
||||
{!finalizado && <AjustesSecao os={os} modo={modo} onChanged={carregar} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-400 mt-1 shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-gray-700">{STATUS_LABEL[ev.status] || ev.observacao || ev.status}</span>
|
||||
<span className="text-gray-400"> · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''}</span>
|
||||
{tab === 'timeline' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><History size={12} /> Linha do tempo</p>
|
||||
<div className="space-y-2.5">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2.5 text-xs">
|
||||
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : ev.status === 'custodia' ? 'bg-blue-400' : 'bg-violet-400'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
|
||||
<span className="text-gray-400 block text-[11px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!finalizado && modo === 'clinica' && (
|
||||
<div className="pt-3 border-t border-gray-100 space-y-2">
|
||||
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto (aba Itens) antes de entregar</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
|
||||
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'itens' && (
|
||||
<div className="space-y-5">
|
||||
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'monitor' && (
|
||||
<div className="space-y-5">
|
||||
<MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />
|
||||
{modo === 'clinica' && <CompartilharLinkSecao os={os} />}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Ajustes do trabalho (cor/material, valor ao paciente, troca) ─────
|
||||
const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: () => void }> = ({ os, modo, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [cor, setCor] = useState(os.cor || '');
|
||||
const [material, setMaterial] = useState(os.material || '');
|
||||
const [delta, setDelta] = useState('');
|
||||
const [obsVal, setObsVal] = useState('');
|
||||
const [trocaOpen, setTrocaOpen] = useState(false);
|
||||
const [novoTrab, setNovoTrab] = useState('');
|
||||
const [motivo, setMotivo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const salvarCM = async () => {
|
||||
setBusy(true);
|
||||
try { await HybridBackend.editarProteseOS(os.id, { cor: cor.trim(), material: material.trim() }); toast.success('TRABALHO ATUALIZADO.'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const ajustar = async (sinal: number) => {
|
||||
const v = parseFloat(delta);
|
||||
if (!(v > 0)) { toast.error('INFORME O VALOR DO AJUSTE.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.ajustarValorProtese(os.id, sinal * v, obsVal.trim() || undefined); toast.success('VALOR DO PACIENTE AJUSTADO.'); setDelta(''); setObsVal(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const trocar = async () => {
|
||||
if (!novoTrab.trim() || !motivo.trim()) { toast.error('INFORME O NOVO TRABALHO E O MOTIVO.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.trocarTrabalhoProtese(os.id, { tipo: novoTrab.trim(), motivo: motivo.trim() }); toast.success('TRABALHO TROCADO.'); setTrocaOpen(false); setNovoTrab(''); setMotivo(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Pencil size={12} /> Ajustes do trabalho</p>
|
||||
{/* Cor / material */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={cor} onChange={e => setCor(e.target.value)} placeholder="Cor (ex.: A2)" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<input value={material} onChange={e => setMaterial(e.target.value)} placeholder="Material" className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button disabled={busy} onClick={salvarCM} className="px-3 py-1.5 rounded-lg bg-gray-700 text-white text-[11px] font-black uppercase disabled:opacity-50">Salvar cor / material</button>
|
||||
|
||||
{/* Ajuste de valor ao paciente — só clínica (protético não vê) */}
|
||||
{modo === 'clinica' && (
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<p className="text-[10px] font-black text-gray-500 uppercase">Ajustar valor do paciente {Number(os.valor) > 0 ? `· atual ${fmtBRL(os.valor)}` : ''}</p>
|
||||
<div className="flex gap-2">
|
||||
<input value={delta} onChange={e => setDelta(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-24 p-2 bg-white border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
<input value={obsVal} onChange={e => setObsVal(e.target.value)} placeholder="Motivo (ex.: pino extra)" className="flex-1 p-2 bg-white border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => ajustar(1)} className="flex-1 py-1.5 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase disabled:opacity-50">+ Acrescentar</button>
|
||||
<button disabled={busy} onClick={() => ajustar(-1)} className="flex-1 py-1.5 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50">− Reduzir</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trocar trabalho */}
|
||||
<button onClick={() => setTrocaOpen(o => !o)} className="px-3 py-1.5 rounded-lg border border-violet-200 text-violet-700 text-[11px] font-black uppercase hover:bg-violet-50 flex items-center gap-1.5"><RefreshCw size={12} /> Solicitar troca de trabalho</button>
|
||||
{trocaOpen && (
|
||||
<div className="bg-violet-50 rounded-xl p-3 space-y-2">
|
||||
<input value={novoTrab} onChange={e => setNovoTrab(e.target.value)} placeholder="Novo trabalho (ex.: PPR metálica)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
|
||||
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da troca (obrigatório)" className="w-full p-2 bg-white border border-violet-100 rounded-lg text-xs outline-none focus:border-violet-400" />
|
||||
<button disabled={busy} onClick={trocar} className="w-full py-2 rounded-lg bg-violet-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Confirmar troca</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ────────────
|
||||
const OCOR_CATS: Record<string, string> = {
|
||||
nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível',
|
||||
parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta',
|
||||
ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro',
|
||||
};
|
||||
const OCOR_STATUS_COR: Record<string, string> = {
|
||||
aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700',
|
||||
resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cat, setCat] = useState('danificado');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [resp, setResp] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [respostas, setRespostas] = useState<Record<string, string>>({});
|
||||
const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado';
|
||||
const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase());
|
||||
|
||||
const abrir = async () => {
|
||||
if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); }
|
||||
catch (e) { err(e); } finally { setBusy(false); }
|
||||
};
|
||||
const responder = async (id: string) => {
|
||||
const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); }
|
||||
};
|
||||
const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } };
|
||||
const anexar = async (id: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]; if (!f) return; setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
|
||||
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="bg-red-50 rounded-xl p-3 space-y-2">
|
||||
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
|
||||
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
|
||||
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
|
||||
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{(os.ocorrencias || []).map((o: any) => (
|
||||
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
|
||||
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
|
||||
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{o.descricao}</p>
|
||||
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
|
||||
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
|
||||
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
|
||||
{o.status !== 'resolvida' && (
|
||||
<div className="space-y-1.5 pt-1 border-t border-gray-50">
|
||||
<div className="flex gap-1.5">
|
||||
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
|
||||
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
|
||||
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
|
||||
</div>
|
||||
))}
|
||||
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-300 uppercase font-bold">Só ocorrências resolvidas pesarão em indicadores (futuro).</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Compartilhar link de acompanhamento com o protético (Modo 1) ─────
|
||||
const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [st, setSt] = useState<any>(null); // null=carregando | {ativo,...}
|
||||
const [busy, setBusy] = useState(false);
|
||||
const carregar = useCallback(() => HybridBackend.getProteseLinkStatus(os.id).then(setSt).catch(() => setSt({ ativo: false })), [os.id]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
const acao = async (fn: () => Promise<any>, ok: string) => {
|
||||
setBusy(true);
|
||||
try { await fn(); await carregar(); toast.success(ok); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const copiar = async () => { try { await navigator.clipboard.writeText(st.url); toast.success('LINK COPIADO.'); } catch { toast.error('NÃO FOI POSSÍVEL COPIAR.'); } };
|
||||
const revogar = async () => { if (await confirm('REVOGAR O LINK? O PROTÉTICO PERDE O ACESSO.')) acao(() => HybridBackend.revogarProteseLink(os.id), 'LINK REVOGADO.'); };
|
||||
const waText = st?.url ? encodeURIComponent(`Acompanhe a prótese (${os.tipo}) do paciente ${os.paciente_nome || ''}: ${st.url}`) : '';
|
||||
return (
|
||||
<div className="border-t border-gray-100 pt-4 space-y-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Share2 size={12} /> Acompanhamento do protético</p>
|
||||
{!st ? (
|
||||
<p className="text-[11px] text-gray-300 uppercase font-bold">Carregando…</p>
|
||||
) : !st.ativo ? (
|
||||
<>
|
||||
<p className="text-[11px] text-gray-400">Gere um link para o protético acompanhar esta OS <b>sem precisar de conta</b>.</p>
|
||||
<button disabled={busy} onClick={() => acao(() => HybridBackend.gerarProteseLink(os.id), 'LINK GERADO.')} 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"><Share2 size={13} /> Gerar link de acompanhamento</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<input readOnly value={st.url} onClick={e => e.currentTarget.select()} className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] text-gray-600 outline-none" />
|
||||
<div className="flex gap-2">
|
||||
<a href={`https://wa.me/?text=${waText}`} target="_blank" rel="noreferrer" className="flex-1 py-2 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase text-center">WhatsApp</a>
|
||||
<button onClick={copiar} className="flex-1 py-2 rounded-lg border border-gray-200 text-gray-600 text-[11px] font-black uppercase">Copiar</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[9px] font-bold text-gray-400 uppercase">
|
||||
<span>{st.acessos || 0} acesso(s){st.last_access_at ? ` · último ${dataBR(st.last_access_at)}` : ' · ainda não aberto'}</span>
|
||||
<button disabled={busy} onClick={revogar} className="text-red-500 hover:text-red-600 disabled:opacity-50">Revogar</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-400 uppercase">{st.expires_at ? `Expira em ${dataBR(st.expires_at)} · ` : ''}sem CPF nem valores do paciente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
||||
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><MapPin size={12} /> Onde está o trabalho</p>
|
||||
<div className={`rounded-2xl p-5 text-center ${noLab ? 'bg-violet-50' : 'bg-blue-50'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl mx-auto flex items-center justify-center text-white ${noLab ? 'bg-violet-600' : 'bg-blue-600'}`}>{noLab ? <FlaskConical size={26} /> : <Building2 size={26} />}</div>
|
||||
<p className={`mt-3 font-black uppercase text-sm ${noLab ? 'text-violet-700' : 'text-blue-700'}`}>{noLab ? 'Com o laboratório' : 'Na clínica'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">{noLab ? 'O protético está com o trabalho' : 'O trabalho está na clínica'}</p>
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy || noLab} onClick={() => onMover('laboratorio')} className="flex-1 py-2.5 rounded-xl bg-violet-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><FlaskConical size={14} /> Protético retirou</button>
|
||||
<button disabled={busy || !noLab} onClick={() => onMover('clinica')} className="flex-1 py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-40 flex items-center justify-center gap-2"><Building2 size={14} /> Devolveu à clínica</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Movimentações</p>
|
||||
<div className="space-y-2">
|
||||
{(os.custodia || []).map((c: any) => (
|
||||
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-200 text-gray-600">{c.de_local === 'clinica' ? 'Clínica' : 'Lab'} → {c.para_local === 'clinica' ? 'Clínica' : 'Lab'}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-1 truncate">{c.actor_nome} · {dataHoraBR(c.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(os.custodia || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Sem movimentações registradas.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Stepper visual das etapas da OS ─────────────────────────────────────────
|
||||
const FLUXO_OS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
|
||||
const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
|
||||
if (os.status === 'cancelado') {
|
||||
return <div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase flex items-center gap-2"><X size={14} /> OS cancelada</div>;
|
||||
}
|
||||
const idxAtual = FLUXO_OS.indexOf(os.status);
|
||||
const dataDe = (s: string) => { const ev = os.eventos?.find((e: any) => e.status === s); return ev ? dataBR(ev.created_at) : ''; };
|
||||
return (
|
||||
<div className="flex items-start overflow-x-auto pb-1">
|
||||
{FLUXO_OS.map((s, i) => {
|
||||
const done = i < idxAtual, atual = i === idxAtual;
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex flex-col items-center min-w-[58px]">
|
||||
{done ? <CheckCircle2 size={20} className="text-teal-600" />
|
||||
: atual ? <div className="w-5 h-5 rounded-full bg-violet-600 ring-4 ring-violet-100" />
|
||||
: <Circle size={20} className="text-gray-200" />}
|
||||
<span className={`text-[8px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-violet-700' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[8px] text-gray-300 leading-tight">{dataDe(s)}</span>
|
||||
</div>
|
||||
{i < FLUXO_OS.length - 1 && <div className={`flex-1 h-0.5 mt-2.5 min-w-[10px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
|
||||
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
|
||||
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [nome, setNome] = useState('');
|
||||
const [qtd, setQtd] = useState('1');
|
||||
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const add = async () => {
|
||||
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
|
||||
<div className="space-y-1.5">
|
||||
{(os.componentes || []).map((c: any) => (
|
||||
<div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
|
||||
<span className="font-black text-gray-700">{c.quantidade}×</span>
|
||||
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
|
||||
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
|
||||
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
|
||||
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações por papel */}
|
||||
{!finalizado && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
|
||||
{modo === 'bancada' ? (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LAB_FLUXO.map(s => (
|
||||
<button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)}
|
||||
className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer">
|
||||
<ImagePlus size={15} /> Anexar foto do trabalho
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto antes de entregar (garantia)</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => mudar('entregue')} className="flex-1 py-2.5 rounded-xl bg-green-600 text-white font-black text-sm uppercase disabled:opacity-50">Marcar entregue</button>
|
||||
<button disabled={busy} onClick={() => mudar('cancelado')} className="px-4 py-2.5 rounded-xl border border-red-200 text-red-600 font-black text-sm uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{naGarantia && modo === 'clinica' && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white">
|
||||
<button disabled={busy} onClick={acionarGarantia} className="w-full py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><ShieldCheck size={16} /> Acionar garantia (refazer sem custo)</button>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
|
||||
<option value="enviado">Enviado</option>
|
||||
<option value="devolvido">Devolvido</option>
|
||||
</select>
|
||||
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Fotos (anexar com legenda, autor, remover) ───────────────────────
|
||||
const FotosSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [legenda, setLegenda] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.uploadProteseFoto(os.id, file, legenda.trim() || undefined); setLegenda(''); onChanged(); }
|
||||
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); } finally { setBusy(false); e.target.value = ''; }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProteseFoto(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Camera size={12} /> Fotos do trabalho</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{(os.fotos || []).map((f: any) => (
|
||||
<div key={f.id} className="rounded-lg border border-gray-100 overflow-hidden group relative">
|
||||
<a href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-full h-24 object-cover" /></a>
|
||||
{podeEditar && <button onClick={() => del(f.id)} className="absolute top-1 right-1 bg-white/90 rounded-md p-1 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={12} /></button>}
|
||||
<div className="px-2 py-1">
|
||||
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate">{f.legenda}</p>}
|
||||
<p className="text-[9px] text-gray-400 truncate">{f.uploaded_nome || '—'} · {dataBR(f.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(os.fotos || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase col-span-full">Nenhuma foto anexada.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input value={legenda} onChange={e => setLegenda(e.target.value)} placeholder="Legenda da próxima foto (opcional)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<label className={`flex items-center gap-1.5 px-3 rounded-lg border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<ImagePlus size={14} /> Anexar
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Seção: Pagamentos (dois lados) ──────────────────────────────────────────
|
||||
const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEditar: boolean; onChanged: () => void }> = ({ os, modo, podeEditar, onChanged }) => {
|
||||
const toast = useToast();
|
||||
const [lado, setLado] = useState<'lab' | 'clinica'>(modo === 'bancada' ? 'lab' : 'clinica');
|
||||
const [valor, setValor] = useState('');
|
||||
const [forma, setForma] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pags = os.pagamentos || [];
|
||||
const totalLab = pags.filter((p: any) => p.lado === 'lab').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
|
||||
const totalClinica = pags.filter((p: any) => p.lado === 'clinica').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
|
||||
const add = async () => {
|
||||
const v = parseFloat(valor);
|
||||
if (!(v > 0)) { toast.error('INFORME UM VALOR.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.addProtesePagamento(os.id, { lado, valor: v, forma: forma.trim() || undefined }); setValor(''); setForma(''); onChanged(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const del = async (id: string) => { try { await HybridBackend.deleteProtesePagamento(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
|
||||
{/* O protético (modo bancada) NÃO vê o que o paciente paga — só o que recebe do lab. */}
|
||||
<div className={`grid ${modo === 'bancada' ? 'grid-cols-1' : 'grid-cols-2'} gap-2 mb-2`}>
|
||||
{modo === 'clinica' && <div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>}
|
||||
<div className="bg-violet-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-violet-600 uppercase">Pago ao laboratório</p><p className="text-sm font-black text-violet-700">{fmtBRL(totalLab)}</p></div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{pags.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase ${p.lado === 'lab' ? 'bg-violet-100 text-violet-700' : 'bg-green-100 text-green-700'}`}>{p.lado === 'lab' ? 'Lab' : 'Paciente'}</span>
|
||||
<span className="font-black text-gray-700">{fmtBRL(p.valor)}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-1 truncate">{p.forma ? `${p.forma} · ` : ''}{p.recebido_nome} · {dataBR(p.data)}</span>
|
||||
{podeEditar && <button onClick={() => del(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
||||
</div>
|
||||
))}
|
||||
{pags.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum pagamento registrado.</p>}
|
||||
</div>
|
||||
{podeEditar && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{modo === 'clinica' ? (
|
||||
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
|
||||
<option value="clinica">Paciente</option>
|
||||
<option value="lab">Lab</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="p-2 bg-violet-50 rounded-lg text-xs font-black text-violet-700 uppercase flex items-center">Lab</span>
|
||||
)}
|
||||
<input value={valor} onChange={e => setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
<input value={forma} onChange={e => setForma(e.target.value)} placeholder="Forma (pix...)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -467,6 +965,10 @@ export const ProteseView: React.FC = () => {
|
||||
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
|
||||
</p>
|
||||
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
|
||||
{modo === 'bancada' && os.clinica_nome && (
|
||||
<p className="text-[10px] font-black text-blue-600 uppercase truncate flex items-center gap-1"><Building2 size={10} /> {os.clinica_nome}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}
|
||||
|
||||
+102
-14
@@ -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; // 07h–22h, 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ 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)
|
||||
}
|
||||
|
||||
interface Proposta {
|
||||
@@ -699,6 +700,7 @@ 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}</span>}
|
||||
</div>
|
||||
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user