Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd89ca245c | |||
| 07d798c370 | |||
| 6a88995146 | |||
| f5a6dc7ea0 | |||
| b5d093d2ce | |||
| 489d2753ee |
@@ -4211,6 +4211,93 @@ app.get('/api/protese/os/:id/link/status', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Fatia 3: Cotação (RFQ) — clínica pede, N labs cotam, clínica escolhe → vira OS ──
|
||||
app.post('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const b = req.body || {};
|
||||
const proteticos = Array.isArray(b.proteticos) ? b.proteticos.filter(Boolean) : [];
|
||||
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva o trabalho.' });
|
||||
if (!proteticos.length) return res.status(400).json({ error: 'Convide ao menos um laboratório.' });
|
||||
try {
|
||||
const uid = req.authUser?.userId;
|
||||
const rfqId = `rfq_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO protese_rfq (id, clinica_id, descricao, dentes, prazo_desejado, paciente_id, paciente_nome, dentista_id, dentista_nome, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[rfqId, req.clinicaId, String(b.descricao).trim(), b.dentes || null, b.prazo_desejado || null, b.paciente_id || null, b.paciente_nome || null, b.dentista_id || null, b.dentista_nome || null, uid]);
|
||||
let convidados = 0;
|
||||
for (const pid of proteticos) {
|
||||
if (!await proteticoAcessivelPelaClinica(pid, req.clinicaId)) continue;
|
||||
const { rows: pr } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [pid]);
|
||||
if (!pr.length) continue;
|
||||
await pool.query(`INSERT INTO protese_rfq_cotacao (id, rfq_id, protetico_id, protetico_nome) VALUES ($1,$2,$3,$4)`,
|
||||
[`cot_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${convidados}`, rfqId, pid, pr[0].nome]);
|
||||
await notificarUsuario(pid, 'Novo pedido de cotação', 'Você recebeu um pedido de cotação de prótese. Responda em Cotações.');
|
||||
convidados++;
|
||||
}
|
||||
res.json({ success: true, id: rfqId, convidados });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.get('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows: rfqs } = await pool.query("SELECT * FROM protese_rfq WHERE clinica_id=$1 ORDER BY created_at DESC LIMIT 100", [req.clinicaId]);
|
||||
const ids = rfqs.map(r => r.id);
|
||||
let cots = [];
|
||||
if (ids.length) { const { rows } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE rfq_id = ANY($1) ORDER BY (valor IS NULL), valor ASC", [ids]); cots = rows; }
|
||||
res.json(rfqs.map(r => ({ ...r, cotacoes: cots.filter(c => c.rfq_id === r.id).map(c => ({ ...c, valor: c.valor != null ? parseFloat(c.valor) : null })) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.get('/api/protese/rfq/recebidas', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.id AS cotacao_id, c.valor, c.prazo_dias, c.observacao, c.status AS cotacao_status,
|
||||
r.id AS rfq_id, r.descricao, r.dentes, r.prazo_desejado, r.paciente_nome, r.dentista_nome, r.status AS rfq_status, r.created_at,
|
||||
(SELECT nome_fantasia FROM clinicas WHERE id=r.clinica_id) AS clinica_nome
|
||||
FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id = c.rfq_id
|
||||
WHERE c.protetico_id=$1 ORDER BY r.created_at DESC LIMIT 100`, [req.authUser.userId]);
|
||||
res.json(rows.map(r => ({ ...r, valor: r.valor != null ? parseFloat(r.valor) : null })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/rfq/cotacao/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query("SELECT c.*, r.status AS rfq_status FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id=c.rfq_id WHERE c.id=$1", [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||||
if (rows[0].protetico_id !== req.authUser.userId) return res.status(403).json({ error: 'Não é sua cotação.' });
|
||||
if (rows[0].rfq_status !== 'aberta') return res.status(409).json({ error: 'Pedido de cotação encerrado.' });
|
||||
const valor = Number(req.body?.valor);
|
||||
if (!(valor > 0)) return res.status(400).json({ error: 'Informe o valor da cotação.' });
|
||||
await pool.query("UPDATE protese_rfq_cotacao SET valor=$1, prazo_dias=$2, observacao=$3, status='enviada', respondida_em=NOW() WHERE id=$4",
|
||||
[valor, parseInt(req.body?.prazo_dias, 10) || null, req.body?.observacao || null, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows: rr } = await pool.query("SELECT * FROM protese_rfq WHERE id=$1 AND clinica_id=$2", [req.params.id, req.clinicaId]);
|
||||
if (!rr.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||||
const rfq = rr[0];
|
||||
if (rfq.status !== 'aberta') return res.status(409).json({ error: 'Pedido já encerrado.' });
|
||||
const { rows: cc } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE id=$1 AND rfq_id=$2 AND status='enviada'", [req.body?.cotacao_id, rfq.id]);
|
||||
if (!cc.length) return res.status(400).json({ error: 'Cotação inválida.' });
|
||||
const cot = cc[0];
|
||||
const uid = req.authUser?.userId;
|
||||
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const labId = await labDoProtetico(cot.protetico_id);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, tipo, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,'solicitado',$15)`,
|
||||
[osId, req.clinicaId, cot.protetico_id, labId, cot.protetico_nome, rfq.paciente_id, rfq.paciente_nome, rfq.dentista_id, rfq.dentista_nome, rfq.descricao, rfq.dentes, rfq.descricao, rfq.prazo_desejado, parseFloat(cot.valor || 0), uid]);
|
||||
await pool.query(`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, req.clinicaId, 'OS criada a partir de cotação', uid, await actorNome(uid)]);
|
||||
await pool.query("UPDATE protese_rfq SET status='fechada', os_id=$1 WHERE id=$2", [osId, rfq.id]);
|
||||
await pool.query("UPDATE protese_rfq_cotacao SET status=CASE WHEN id=$1 THEN 'escolhida' ELSE 'recusada' END WHERE rfq_id=$2", [cot.id, rfq.id]);
|
||||
await notificarUsuario(cot.protetico_id, 'Cotação aceita!', 'Sua cotação foi escolhida — uma OS entrou na sua Bancada.');
|
||||
res.json({ success: true, os_id: osId });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente.
|
||||
app.get('/api/p/:token', async (req, res) => {
|
||||
try {
|
||||
@@ -6450,6 +6537,38 @@ async function runMigrations() {
|
||||
acessos INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`,
|
||||
// Marketplace transacional Fatia 3 — Cotação (RFQ): a clínica descreve um trabalho e N
|
||||
// laboratórios cotam (preço/prazo); a clínica escolhe e vira OS.
|
||||
`CREATE TABLE IF NOT EXISTS protese_rfq (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT NOT NULL,
|
||||
descricao TEXT NOT NULL,
|
||||
dentes TEXT,
|
||||
prazo_desejado DATE,
|
||||
paciente_id TEXT,
|
||||
paciente_nome TEXT,
|
||||
dentista_id TEXT,
|
||||
dentista_nome TEXT,
|
||||
status TEXT DEFAULT 'aberta',
|
||||
os_id TEXT,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_clinica ON protese_rfq (clinica_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_rfq_cotacao (
|
||||
id TEXT PRIMARY KEY,
|
||||
rfq_id TEXT NOT NULL,
|
||||
protetico_id TEXT NOT NULL,
|
||||
protetico_nome TEXT,
|
||||
valor NUMERIC,
|
||||
prazo_dias INTEGER,
|
||||
observacao TEXT,
|
||||
status TEXT DEFAULT 'convidada',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
respondida_em TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_rfq ON protese_rfq_cotacao (rfq_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Marketplace transacional de Prótese
|
||||
|
||||
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
|
||||
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
|
||||
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
|
||||
|
||||
## O que já existe (não recriar)
|
||||
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
|
||||
ScoreOdonto** no card (feito).
|
||||
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
|
||||
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
|
||||
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
|
||||
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
|
||||
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
|
||||
|
||||
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
|
||||
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
|
||||
|
||||
## Fatias
|
||||
|
||||
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
|
||||
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
|
||||
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
|
||||
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
||||
prótese"** (proposta com contexto de prótese).
|
||||
|
||||
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
|
||||
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
|
||||
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
|
||||
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
|
||||
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
|
||||
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
|
||||
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
|
||||
|
||||
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
|
||||
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
|
||||
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
|
||||
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
|
||||
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
|
||||
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
|
||||
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
|
||||
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
|
||||
|
||||
### Fatia 4 — Reputação pública e conversão
|
||||
Avaliação da clínica ao final da OS (alimenta a Nota, hoje só operacional), selo "lab verificado",
|
||||
ordenar marketplace por nota dentro do raio.
|
||||
|
||||
## Decisões de modelagem
|
||||
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
||||
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
|
||||
preservando rastreabilidade/financeiro/score.
|
||||
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
### 1. Onde está o código?
|
||||
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
|
||||
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.25 · commit `8040e70`**.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.28 · commit `6a88995`**.
|
||||
- **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.25 · `8040e70` · 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.28 · `6a88995` · 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.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.
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.28**), 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.
|
||||
|
||||
+7
-2
@@ -33,6 +33,7 @@ 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 { LabCotacoesView } from './views/LabCotacoesView.tsx';
|
||||
import { LabEquipeView } from './views/LabEquipeView.tsx';
|
||||
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
@@ -92,6 +93,7 @@ export type ViewKey =
|
||||
| 'lab-bancada'
|
||||
| 'lab-clientes'
|
||||
| 'lab-financeiro'
|
||||
| 'lab-cotacoes'
|
||||
| 'lab-equipe'
|
||||
| 'lab-indicadores'
|
||||
| 'marketplace-profissionais'
|
||||
@@ -148,6 +150,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/lab-bancada': 'lab-bancada',
|
||||
'/lab-clientes': 'lab-clientes',
|
||||
'/lab-financeiro': 'lab-financeiro',
|
||||
'/lab-cotacoes': 'lab-cotacoes',
|
||||
'/lab-equipe': 'lab-equipe',
|
||||
'/lab-indicadores': 'lab-indicadores',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
@@ -209,6 +212,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'lab-bancada': '/lab-bancada',
|
||||
'lab-clientes': '/lab-clientes',
|
||||
'lab-financeiro': '/lab-financeiro',
|
||||
'lab-cotacoes': '/lab-cotacoes',
|
||||
'lab-equipe': '/lab-equipe',
|
||||
'lab-indicadores': '/lab-indicadores',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
@@ -310,7 +314,7 @@ const App: React.FC = () => {
|
||||
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-cotacoes', '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.
|
||||
@@ -329,7 +333,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: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
@@ -506,6 +510,7 @@ const App: React.FC = () => {
|
||||
case 'lab-bancada': return <ProteseView />;
|
||||
case 'lab-clientes': return <LabClientesView tab="clientes" />;
|
||||
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
|
||||
case 'lab-cotacoes': return <LabCotacoesView />;
|
||||
case 'lab-equipe': return <LabEquipeView />;
|
||||
case 'lab-indicadores': return <LabIndicadoresView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ 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-cotacoes', label: 'COTAÇÕES', icon: FileText },
|
||||
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
|
||||
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
|
||||
@@ -104,7 +105,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
|
||||
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
|
||||
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
|
||||
if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
if (['lab-home', 'lab-bancada', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
@@ -117,7 +118,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
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);
|
||||
: ['lab-home', 'lab-bancada', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
}
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
|
||||
@@ -1134,6 +1134,33 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
|
||||
return await res.json().catch(() => ({ ativo: false }));
|
||||
},
|
||||
// Cotação (RFQ) — Fatia 3
|
||||
criarRfq: async (body: any) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao pedir cotação');
|
||||
return await res.json();
|
||||
},
|
||||
getRfqs: async (): Promise<any[]> => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getRfqRecebidas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/recebidas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
responderCotacao: async (cotacaoId: string, body: { valor: number; prazo_dias?: number; observacao?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/cotacao/${cotacaoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cotar');
|
||||
return await res.json();
|
||||
},
|
||||
escolherCotacao: async (rfqId: string, cotacaoId: string) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/${rfqId}/escolher?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cotacao_id: cotacaoId }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao escolher');
|
||||
return await res.json();
|
||||
},
|
||||
revogarProteseLink: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
|
||||
export const LabCotacoesView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
|
||||
catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
|
||||
const responder = async (c: any) => {
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
const valor = parseFloat(f.valor);
|
||||
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
|
||||
setBusy(c.cotacao_id);
|
||||
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="PEDIDOS DE COTAÇÃO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
||||
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
{lista.map(c => {
|
||||
const aberta = c.rfq_status === 'aberta';
|
||||
const ganhou = c.cotacao_status === 'escolhida';
|
||||
const perdeu = c.cotacao_status === 'recusada';
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
return (
|
||||
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
|
||||
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
|
||||
</div>
|
||||
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
|
||||
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
|
||||
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
|
||||
</div>
|
||||
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
|
||||
|
||||
{aberta && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
|
||||
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
|
||||
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
|
||||
</div>
|
||||
)}
|
||||
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -885,6 +885,103 @@ const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
};
|
||||
|
||||
// ── View principal ──────────────────────────────────────────────────────────
|
||||
// ── Modal: Cotações (lado clínica) — pedir a N labs, comparar e escolher ────
|
||||
const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => void }> = ({ onClose, onEscolhida }) => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [aba, setAba] = useState<'novo' | 'lista'>('lista');
|
||||
const [rfqs, setRfqs] = useState<any[]>([]);
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [descricao, setDescricao] = useState('');
|
||||
const [dentes, setDentes] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
|
||||
useEffect(() => { carregar(); HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); }, [carregar]);
|
||||
|
||||
const toggleLab = (id: string) => setSel(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
|
||||
const criar = async () => {
|
||||
if (!descricao.trim()) { toast.error('DESCREVA O TRABALHO.'); return; }
|
||||
if (!sel.length) { toast.error('SELECIONE AO MENOS UM LABORATÓRIO.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.criarRfq({ descricao: descricao.trim(), dentes: dentes.trim() || undefined, prazo_desejado: prazo || undefined, proteticos: sel }); toast.success('COTAÇÃO ENVIADA AOS LABORATÓRIOS.'); setDescricao(''); setDentes(''); setPrazo(''); setSel([]); setAba('lista'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const escolher = async (rfq: any, cot: any) => {
|
||||
if (!(await confirm(`ESCOLHER ${cot.protetico_nome} POR ${fmtBRL(cot.valor)}? UMA OS SERÁ CRIADA.`))) return;
|
||||
setBusy(true);
|
||||
try { await HybridBackend.escolherCotacao(rfq.id, cot.id); toast.success('OS CRIADA A PARTIR DA COTAÇÃO.'); carregar(); onEscolhida(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="flex border-b border-gray-100 px-2">
|
||||
{(['lista', 'novo'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{aba === 'novo' ? (
|
||||
<div className="space-y-3">
|
||||
<textarea value={descricao} onChange={e => setDescricao(e.target.value)} rows={3} placeholder="Descreva o trabalho (ex.: Coroa sobre implante, dente 36, cor A2)" className="w-full p-3 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400 resize-none" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={dentes} onChange={e => setDentes(e.target.value)} placeholder="Dentes (ex.: 36)" className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
<input type="date" value={prazo} onChange={e => setPrazo(e.target.value)} className="p-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||
{labs.map(l => (
|
||||
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
|
||||
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
|
||||
{l.interno && <span className="text-[8px] font-black text-teal-600 uppercase">interno</span>}
|
||||
{l.nota != null && <span className="text-[9px] font-black text-amber-600">★ {l.nota}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={criar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50">Enviar pedido a {sel.length} laboratório(s)</button>
|
||||
</div>
|
||||
) : (
|
||||
rfqs.length === 0 ? <p className="text-center text-gray-300 font-bold uppercase text-sm py-12">Nenhum pedido de cotação ainda.</p> : (
|
||||
<div className="space-y-3">
|
||||
{rfqs.map(r => (
|
||||
<div key={r.id} className="border border-gray-100 rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-black text-gray-800 text-sm">{r.descricao}</p>
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${r.status === 'fechada' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>{r.status === 'fechada' ? 'fechada' : 'aberta'}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{(r.cotacoes || []).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="font-bold text-gray-700 flex-1 truncate">{c.protetico_nome}</span>
|
||||
{c.status === 'convidada' ? <span className="text-[10px] text-gray-400 uppercase">aguardando…</span>
|
||||
: <><span className="font-black text-gray-800">{fmtBRL(c.valor)}</span>{c.prazo_dias ? <span className="text-[10px] text-gray-400">{c.prazo_dias}d</span> : null}</>}
|
||||
{c.status === 'escolhida' && <span className="text-[8px] font-black text-green-700 uppercase">escolhida</span>}
|
||||
{r.status === 'aberta' && c.status === 'enviada' && <button disabled={busy} onClick={() => escolher(r, c)} className="px-2 py-1 rounded-lg bg-[#2d6a4f] text-white text-[9px] font-black uppercase disabled:opacity-50">Escolher</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProteseView: React.FC = () => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const modo: 'bancada' | 'clinica' = role === 'protetico' ? 'bancada' : 'clinica';
|
||||
@@ -896,6 +993,7 @@ export const ProteseView: React.FC = () => {
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
const [detalhe, setDetalhe] = useState<string | null>(null);
|
||||
const [produtosModal, setProdutosModal] = useState(false);
|
||||
const [cotacoesOpen, setCotacoesOpen] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -923,9 +1021,10 @@ export const ProteseView: React.FC = () => {
|
||||
description={modo === 'bancada' ? 'Fila de trabalhos do laboratório' : 'Ordens de serviço enviadas ao laboratório'}
|
||||
>
|
||||
{modo === 'clinica' ? (
|
||||
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase">
|
||||
<Plus size={16} /> Nova OS
|
||||
</button>
|
||||
<>
|
||||
<button onClick={() => setCotacoesOpen(true)} className="flex items-center gap-2 border border-violet-200 text-violet-700 px-4 py-2 rounded-xl font-black text-sm uppercase hover:bg-violet-50"><FileText size={16} /> Cotações</button>
|
||||
<button onClick={() => setNovaOS(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Plus size={16} /> Nova OS</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => setProdutosModal(true)} className="flex items-center gap-2 bg-violet-600 text-white px-4 py-2 rounded-xl font-black text-sm uppercase"><Tags size={16} /> Meus produtos</button>
|
||||
@@ -986,6 +1085,7 @@ export const ProteseView: React.FC = () => {
|
||||
{novaOS && <NovaOSModal onClose={() => setNovaOS(false)} onSaved={() => { setNovaOS(false); carregar(); }} />}
|
||||
{detalhe && <OSDetailModal id={detalhe} modo={modo} onClose={() => setDetalhe(null)} onChanged={carregar} />}
|
||||
{produtosModal && <ProdutosModal onClose={() => setProdutosModal(false)} />}
|
||||
{cotacoesOpen && <CotacoesClinicaModal onClose={() => setCotacoesOpen(false)} onEscolhida={() => carregar()} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,150 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
UserSearch, Stethoscope, Wrench, Sparkles, MapPin, Search, Mail, Phone, RefreshCw,
|
||||
Save, BadgeCheck, Inbox, Send, CheckCircle2, XCircle, CircleDollarSign, UserCircle2, X,
|
||||
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2
|
||||
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2, Tags, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
|
||||
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
const [paciente, setPaciente] = useState<any>(null);
|
||||
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [okMsg, setOkMsg] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [prof.id, ws?.id]);
|
||||
useEffect(() => {
|
||||
if (paciente) return;
|
||||
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [busca, paciente]);
|
||||
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
||||
|
||||
const salvar = async () => {
|
||||
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
|
||||
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||
setSaving(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
...form, protetico_id: prof.id, valor: 0,
|
||||
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
|
||||
});
|
||||
setOkMsg(true);
|
||||
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
{okMsg ? (
|
||||
<div className="p-8 text-center space-y-2">
|
||||
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
|
||||
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
|
||||
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
|
||||
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
|
||||
{produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Escolha o trabalho…</option>
|
||||
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} — {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
||||
{paciente ? (
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
|
||||
) : (
|
||||
<div className="relative mt-1">
|
||||
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
||||
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
{busca && pacientes.length > 0 && (
|
||||
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
||||
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">—</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
|
||||
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
|
||||
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
|
||||
</div>
|
||||
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
||||
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
||||
const [aberto, setAberto] = useState(false);
|
||||
const [produtos, setProdutos] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toggle = async () => {
|
||||
if (!aberto && produtos === null) {
|
||||
setLoading(true);
|
||||
try { const p = await HybridBackend.getLaboratorioProdutos(proteticoId); setProdutos(Array.isArray(p) ? p : []); }
|
||||
catch { setProdutos([]); } finally { setLoading(false); }
|
||||
}
|
||||
setAberto(a => !a);
|
||||
};
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button onClick={toggle} className="w-full py-2 rounded-xl border border-gray-200 text-gray-600 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 hover:bg-gray-50">
|
||||
<Tags size={12} /> {aberto ? 'Ocultar catálogo' : 'Ver catálogo de próteses'} {aberto ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{aberto && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{loading ? <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Carregando…</p>
|
||||
: produtos && produtos.length ? produtos.map(pr => (
|
||||
<div key={pr.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-1.5">
|
||||
<span className="font-bold text-gray-700 flex-1 truncate">{pr.nome}</span>
|
||||
{pr.garantia_meses ? <span className="text-[9px] text-emerald-600 font-black uppercase">{pr.garantia_meses}m gar.</span> : null}
|
||||
<span className="font-black text-gray-800">{fmtBRLp(pr.preco)}</span>
|
||||
</div>
|
||||
)) : <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Sem catálogo publicado.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { PageHeader } from '../../components/PageHeader.tsx';
|
||||
import { useToast } from '../../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
|
||||
@@ -407,6 +549,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
// propostas
|
||||
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
||||
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
||||
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
|
||||
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
||||
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
||||
|
||||
@@ -723,10 +866,11 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
||||
{ws?.id && (
|
||||
<button onClick={() => setProposingTo(p)}
|
||||
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
|
||||
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
<Send size={13} /> ENVIAR PROPOSTA
|
||||
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -895,6 +1039,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
{tab === 'modelos' && <ModelosManager />}
|
||||
|
||||
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
||||
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user