From cd89ca245c3ddc664f6bcc1cae430243754a7072 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 14:43:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20marketplace=20transacional=20f?= =?UTF-8?q?atia=203=20=E2=80=94=20cota=C3=A7=C3=A3o=20(RFQ)=20multi-lab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs; cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS (custo = valor da cotação) e a RFQ fecha (demais recusadas). - Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id, POST /protese/rfq/:id/escolher (cria a OS). - Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder. - Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher. Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 119 ++++++++++++++++++++++++ doc/MARKETPLACE-TRANSACIONAL-PROTESE.md | 12 ++- frontend/App.tsx | 9 +- frontend/components/Sidebar.tsx | 5 +- frontend/services/backend.ts | 27 ++++++ frontend/views/LabCotacoesView.tsx | 85 +++++++++++++++++ frontend/views/ProteseView.tsx | 106 ++++++++++++++++++++- 7 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 frontend/views/LabCotacoesView.tsx diff --git a/backend/server.js b/backend/server.js index f340eca..46082b1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) diff --git a/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md b/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md index fa6e4c8..ac0c8bc 100644 --- a/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md +++ b/doc/MARKETPLACE-TRANSACIONAL-PROTESE.md @@ -32,10 +32,14 @@ vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um mo (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) para trabalho sob medida -Quando não há produto de catálogo: a clínica descreve o trabalho e **N labs cotam** (preço + prazo); -a clínica escolhe e vira OS. Estende `propostas_profissional` com itens de prótese e valores, ou nova -`protese_rfq` + `protese_rfq_cotacao`. +### ✅ 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", diff --git a/frontend/App.tsx b/frontend/App.tsx index 6f598ec..536e1f9 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -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 = { '/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 = { '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 ; case 'lab-clientes': return ; case 'lab-financeiro': return ; + case 'lab-cotacoes': return ; case 'lab-equipe': return ; case 'lab-indicadores': return ; case 'marketplace-profissionais': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 5fa80bb..04615fe 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -64,6 +64,7 @@ export const Sidebar: React.FC = ({ 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 = ({ 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 = ({ 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 diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 1527c55..54a5495 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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 => { + const cid = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`); + return await res.json().catch(() => []); + }, + getRfqRecebidas: async (): Promise => { + 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'); diff --git a/frontend/views/LabCotacoesView.tsx b/frontend/views/LabCotacoesView.tsx new file mode 100644 index 0000000..00bc25c --- /dev/null +++ b/frontend/views/LabCotacoesView.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [forms, setForms] = useState>({}); + 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 ( +
+ + + + + {loading ?
+ : lista.length === 0 ?
Nenhum pedido de cotação.
+ : ( +
+ {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 ( +
+
+
+

{c.descricao}

+

{c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}

+
+ {ganhou && Você ganhou} + {perdeu && Não escolhida} + {aberta && c.cotacao_status === 'enviada' && Cotado · {fmtBRL(c.valor)}} +
+ {(c.paciente_nome || c.prazo_desejado) &&

{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}

} + + {aberta && ( +
+
+ + 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" /> +
+
+ + 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" /> +
+ 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" /> + +
+ )} + {!aberta && !ganhou && !perdeu &&

Encerrado

} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index f0d932d..d2bb1b1 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -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([]); + const [labs, setLabs] = useState([]); + const [descricao, setDescricao] = useState(''); + const [dentes, setDentes] = useState(''); + const [prazo, setPrazo] = useState(''); + const [sel, setSel] = useState([]); + 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 ( +
+
e.stopPropagation()}> +
+

Cotações de prótese

+ +
+
+ {(['lista', 'novo'] as const).map(t => ( + + ))} +
+
+ {aba === 'novo' ? ( +
+