From f2eb90ccc81d9d6d87593f509015ae3c7500030e Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 17 Jun 2026 05:20:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20m=C3=B3dulo=20de=20OS=20de=20p?= =?UTF-8?q?r=C3=B3tese=20(cl=C3=ADnica=20=E2=86=94=20laborat=C3=B3rio/prot?= =?UTF-8?q?=C3=A9tico)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: tabelas protese_os/_evento/_foto/_produtos + endpoints /api/protese/* (laboratorios, os, bancada, status, foto/raw assinado, produtos); catálogo do protético; custo de lab → DESPESA na entrega (idempotente, estorno no delete) + garantia com foto. - Acesso: helper proteticoAcessivelPelaClinica (interno por vínculo OU diretório) valida o POST e o catálogo; catálogos passam por tenantGuard; correção do label da notificação (tipo resolvido). - Frontend: ProteseView (Bancada do protético / Minhas OS da clínica) + rota /proteses + item de menu PRÓTESES no Sidebar (dentista/protético/funcionário). Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 385 +++++++++++++++++++++++++ frontend/App.tsx | 11 +- frontend/components/Sidebar.tsx | 7 +- frontend/services/backend.ts | 73 +++++ frontend/views/ProteseView.tsx | 491 ++++++++++++++++++++++++++++++++ 5 files changed, 961 insertions(+), 6 deletions(-) create mode 100644 frontend/views/ProteseView.tsx diff --git a/backend/server.js b/backend/server.js index d5df5bc..ff8fb82 100644 --- a/backend/server.js +++ b/backend/server.js @@ -452,6 +452,36 @@ async function seedBiomedicina(clinicaId) { return true; } +// Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral). +// Garantia padrão de 12 meses por produto. +const PROTESE_PRODUTOS_PADRAO = [ + { nome: 'Coroa de Zircônia', preco: 350 }, + { nome: 'Coroa Metalocerâmica', preco: 250 }, + { nome: 'Coroa Provisória', preco: 80 }, + { nome: 'Faceta / Laminado de Porcelana', preco: 400 }, + { nome: 'Onlay / Inlay Cerâmico', preco: 300 }, + { nome: 'Núcleo Metálico Fundido', preco: 90 }, + { nome: 'Prótese Total (Dentadura)', preco: 600 }, + { nome: 'Prótese Parcial Removível (PPR)', preco: 500 }, + { nome: 'Coroa sobre Implante', preco: 600 }, + { nome: 'Protocolo sobre Implante (arco)', preco: 1800 }, + { nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 }, + { nome: 'Provisório em Resina (unidade)', preco: 60 }, +]; +// Idempotente: só popula se o protético ainda não tem nenhum produto. +async function seedProteseProdutos(proteticoId) { + if (!proteticoId) return false; + const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]); + if (rows.length) return false; + for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) { + const p = PROTESE_PRODUTOS_PADRAO[i]; + await pool.query( + "INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)", + [`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]); + } + return true; +} + // --- REGISTRO DE USUÁRIOS --- app.post('/api/register', async (req, res) => { const { nome, email, senha, role, workspaceNome, especialidade } = req.body; @@ -492,6 +522,8 @@ app.post('/api/register', async (req, res) => { workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }]; // Biomédico já nasce com o catálogo de biomedicina estética no consultório dele. if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } } + // Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia). + if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } } } const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' }); @@ -3616,6 +3648,290 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) => } catch (err) { res.status(500).json({ error: err.message }); } }); +// ═══ PRÓTESES · Ordem de Serviço (OS): clínica/dentista → laboratório/protético ═══ +// Cross-workspace (padrão das propostas): a clínica cria por tenantGuard (clinica_id origem); +// o protético vê na "Bancada" por authGuard (protetico_id = destinatário). +const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado']; + +// Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo). +async function proteseOsAccesso(os, uid) { + if (!os) return { isLab: false, isClinic: false }; + const isLab = !!uid && os.protetico_id === uid; + let isClinic = false; + if (uid) { + const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]); + isClinic = rows.length > 0; + } + return { isLab, isClinic }; +} + +// Um protético é "acessível" por uma clínica se for interno (vínculo) OU estiver no diretório. +// Mesma regra do dropdown de Nova OS — usada para travar criação de OS e leitura de catálogo. +async function proteticoAcessivelPelaClinica(proteticoId, clinicaId) { + if (!proteticoId || !clinicaId) return false; + const { rows } = await pool.query( + `SELECT 1 FROM usuarios u + LEFT JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $2 + WHERE u.id = $1 AND u.role = 'protetico' + AND (v.clinica_id = $2 OR u.disponivel_diretorio = true) + LIMIT 1`, [proteticoId, clinicaId]); + return rows.length > 0; +} + +// Laboratórios/protéticos disponíveis para o dropdown da Nova OS: +// protéticos com vínculo na clínica (internos) + protéticos no diretório (externos). +app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const clinicaId = req.clinicaId; + const { rows } = await pool.query( + `SELECT u.id, u.nome, u.email, + BOOL_OR(v.clinica_id = $1) AS interno + FROM usuarios u + LEFT JOIN vinculos v ON v.usuario_id = u.id + WHERE u.role = 'protetico' + AND (v.clinica_id = $1 OR u.disponivel_diretorio = true) + GROUP BY u.id, u.nome, u.email + ORDER BY interno DESC, u.nome`, [clinicaId]); + res.json(rows.map(r => ({ ...r, interno: !!r.interno }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ── Catálogo de produtos do protético (preço fixo, geral) ─────────────────── +// O protético gerencia o próprio catálogo (escopo por usuário). Lazy-seed na 1ª leitura. +app.get('/api/protese/produtos', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + await seedProteseProdutos(uid); // idempotente — protéticos antigos ganham o catálogo padrão + const { rows } = await pool.query('SELECT * FROM protese_produtos WHERE protetico_id=$1 AND deleted_at IS NULL ORDER BY ordem, nome', [uid]); + res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Catálogo ATIVO de um laboratório (lido pela clínica ao criar a OS). +app.get('/api/protese/laboratorios/:id/produtos', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + if (!await proteticoAcessivelPelaClinica(req.params.id, req.clinicaId)) { + return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' }); + } + await seedProteseProdutos(req.params.id); + const { rows } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE protetico_id=$1 AND ativo=true AND deleted_at IS NULL ORDER BY ordem, nome', [req.params.id]); + res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/protese/produtos', authGuard, async (req, res) => { + const b = req.body || {}; + if (!b.nome) return res.status(400).json({ error: 'nome obrigatório.' }); + try { + const id = `ppr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query('INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [id, req.authUser.userId, b.nome, Number(b.preco) || 0, Number(b.garantia_meses) || 12, b.ativo !== false, Number(b.ordem) || 0]); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.put('/api/protese/produtos/:id', authGuard, async (req, res) => { + const b = req.body || {}; + try { + const { rowCount } = await pool.query( + 'UPDATE protese_produtos SET nome=COALESCE($1,nome), preco=COALESCE($2,preco), garantia_meses=COALESCE($3,garantia_meses), ativo=COALESCE($4,ativo) WHERE id=$5 AND protetico_id=$6 AND deleted_at IS NULL', + [b.nome ?? null, b.preco != null ? Number(b.preco) : null, b.garantia_meses != null ? Number(b.garantia_meses) : null, b.ativo ?? null, req.params.id, req.authUser.userId]); + if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.delete('/api/protese/produtos/:id', authGuard, async (req, res) => { + try { + const { rowCount } = await pool.query('UPDATE protese_produtos SET deleted_at=NOW() WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [req.params.id, req.authUser.userId]); + if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Criar OS (clínica origem). +app.post('/api/protese/os', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + const b = req.body || {}; + if (!b.protetico_id) return res.status(400).json({ error: 'protetico_id (laboratório) obrigatório.' }); + try { + const { rows: prot } = await pool.query("SELECT id, nome FROM usuarios WHERE id=$1 AND role='protetico'", [b.protetico_id]); + if (!prot.length) return res.status(404).json({ error: 'Protético não encontrado.' }); + // Só pode pedir OS a protético interno (vínculo) ou no diretório — mesma regra do dropdown. + if (!await proteticoAcessivelPelaClinica(b.protetico_id, req.clinicaId)) { + return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' }); + } + // Produto do catálogo do laboratório = preço fixo (custo de lab) + garantia. + let tipo = b.tipo || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null; + if (b.produto_id) { + const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, b.protetico_id]); + if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' }); + produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12; + } + if (!tipo) return res.status(400).json({ error: 'Selecione um produto/tipo de prótese.' }); + // OS de garantia: refaz uma OS anterior sem novo custo (dentro da garantia). + const ehGarantia = b.tipo_os === 'garantia' && !!b.origem_os_id; + if (ehGarantia) custo = 0; + const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + const uid = req.authUser?.userId; + await pool.query( + `INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`, + [id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null, + b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null, + tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses, + b.material || null, b.cor || null, b.dentes || null, b.observacoes || null, + b.prazo_entrega || null, Number(b.valor) || 0, custo, 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)}`, id, req.clinicaId, 'OS criada', uid, await actorNome(uid)]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: id, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { protetico_id: b.protetico_id, tipo } }); + await notificarUsuario(b.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${tipo}). Veja na Bancada.`); + wsBroadcast(req.clinicaId, 'protese', { entidadeId: id, acao: 'criou' }); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Lista da clínica origem (dentista/recepção). +app.get('/api/protese/os', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const vals = [req.clinicaId]; + let where = 'clinica_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}`; } + if (req.query.paciente_id) { vals.push(req.query.paciente_id); where += ` AND paciente_id=$${vals.length}`; } + const { rows } = await pool.query(`SELECT * FROM protese_os WHERE ${where} ORDER BY created_at DESC LIMIT 300`, vals); + res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Bancada do laboratório (protético): OS destinadas a mim, com flag de atraso. +app.get('/api/protese/bancada', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const vals = [uid]; + let where = 'protetico_id=$1 AND deleted_at IS NULL'; + if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; } + const { rows } = await pool.query( + `SELECT *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada + FROM protese_os WHERE ${where} + ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals); + res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Detalhe + histórico de eventos + fotos. +app.get('/api/protese/os/:id', authGuard, async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' }); + const os = rows[0]; + const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId); + if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' }); + const { rows: eventos } = await pool.query('SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at', [req.params.id]); + const { rows: fotos } = await pool.query('SELECT id, original_name FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]); + res.json({ + ...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0), + eventos, + fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })), + }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Mudar status (laboratório avança a produção; clínica entrega/cancela). +app.post('/api/protese/os/:id/status', authGuard, async (req, res) => { + const novo = req.body?.status; + if (!PROTESE_STATUS.includes(novo)) return res.status(400).json({ error: 'Status inválido.' }); + try { + const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' }); + const os = rows[0]; + const uid = req.authUser.userId; + const { isLab, isClinic } = await proteseOsAccesso(os, uid); + if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' }); + if (os.status === 'cancelado' || os.status === 'entregue') return res.status(409).json({ error: `OS já está "${os.status}".` }); + // Entregue/cancelado são da clínica; estados de produção são do laboratório. + const estadosClinica = ['entregue', 'cancelado']; + if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' }); + if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' }); + // Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia). + let garantiaAte = null, finId = os.financeiro_id || null; + if (novo === 'entregue') { + const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]); + if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' }); + const meses = Number(os.garantia_meses) || 12; + const { rows: gd } = await pool.query(`SELECT (CURRENT_DATE + ($1 || ' months')::interval)::date AS d`, [meses]); + garantiaAte = gd[0].d; + // Custo de laboratório → DESPESA (contas a pagar ao laboratório), idempotente. OS de garantia não cobra. + if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') { + finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query( + `INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao) + VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`, + [finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo), + os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]); + } + } + await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id) WHERE id=$4', [novo, garantiaAte, finId, os.id]); + await pool.query( + `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]); + await registrarAudit(pool, { clinicaId: os.clinica_id, entidade: 'protese_os', entidadeId: os.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { financeiro_id: finId, garantia_ate: garantiaAte } }); + // Notifica a outra parte. + const destino = isLab ? os.created_by : os.protetico_id; + if (destino) await notificarUsuario(destino, `Prótese: ${novo}`, `A OS de ${os.paciente_nome || 'paciente'} (${os.tipo}) mudou para "${novo}".`, novo === 'entregue' ? 'sucesso' : 'info'); + wsBroadcast(os.clinica_id, 'protese', { entidadeId: os.id, acao: novo }); + res.json({ success: true, status: novo, financeiro_id: finId, garantia_ate: garantiaAte }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Anexar foto do trabalho (laboratório ou clínica). +app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' }); + try { + const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' }); + const os = rows[0]; + const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId); + if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' }); + const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg'; + const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`); + await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true }); + await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); + const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', + [fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]); + res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Stream da imagem (URL assinada — img tag não envia Authorization). +app.get('/api/protese/os/fotos/:id/raw', async (req, res) => { + try { + if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end(); + const { rows } = await pool.query('SELECT filename, mimetype FROM protese_os_foto WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).end(); + res.type(rows[0].mimetype || 'image/jpeg'); + res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); }); + } catch { res.status(404).end(); } +}); + +// Excluir OS (só a clínica de origem; soft delete). +app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => { + if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); + try { + const { rows } = await pool.query('SELECT financeiro_id FROM protese_os WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]); + if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' }); + await pool.query('UPDATE protese_os SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]); + // Estorna a DESPESA de laboratório vinculada (se houver). + if (rows[0].financeiro_id) await pool.query('UPDATE financeiro SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]); + await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: req.params.id, acao: 'excluiu', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { estornou: rows[0].financeiro_id || null } }); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // --- CONTRATOS --- app.get('/api/orcamentos', tenantGuard, async (req, res) => { try { @@ -5142,6 +5458,75 @@ async function runMigrations() { created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`, + // ── PRÓTESES: Ordem de Serviço (clínica → laboratório/protético) ───── + `CREATE TABLE IF NOT EXISTS protese_os ( + id TEXT PRIMARY KEY, + clinica_id TEXT NOT NULL, -- clínica origem (quem pediu) — escopo tenantGuard + laboratorio_id TEXT, -- workspace destino (futuro; null = roteia por protetico_id) + protetico_id TEXT, -- usuário destinatário (a "bancada") + protetico_nome TEXT, + paciente_id TEXT, + paciente_nome TEXT, + dentista_id TEXT, + dentista_nome TEXT, + procedimento_id TEXT, + agendamento_id TEXT, + tipo TEXT, -- fixa | removível | total | sobre implante | provisório... + material TEXT, + cor TEXT, -- escala de cor + dentes TEXT, -- FDI livre (ex: 11,21) + observacoes TEXT, + prazo_entrega DATE, + valor NUMERIC DEFAULT 0, -- preço cobrado pela OS + custo NUMERIC DEFAULT 0, -- custo de laboratório (elo futuro com comissão) + status TEXT DEFAULT 'solicitado',-- solicitado|recebido|producao|prova|ajuste|finalizado|entregue|cancelado + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_clinica ON protese_os (clinica_id, status, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_lab ON protese_os (protetico_id, status)`, + `CREATE TABLE IF NOT EXISTS protese_os_evento ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + clinica_id TEXT, + status TEXT, + observacao TEXT, + actor_id TEXT, + actor_nome TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_evento ON protese_os_evento (os_id, created_at)`, + `CREATE TABLE IF NOT EXISTS protese_os_foto ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + clinica_id TEXT, + filename TEXT, + mimetype TEXT, + original_name TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_foto ON protese_os_foto (os_id)`, + // Catálogo de produtos do protético (preço fixo, sem especialidade — geral de prótese) + garantia + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS produto_id TEXT`, + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS tipo_os TEXT DEFAULT 'nova'`, // nova | garantia + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS origem_os_id TEXT`, // OS original (em garantia) + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`, + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`, + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega + `CREATE TABLE IF NOT EXISTS protese_produtos ( + id TEXT PRIMARY KEY, + protetico_id TEXT NOT NULL, -- dono (usuário protético) + nome TEXT NOT NULL, + preco NUMERIC DEFAULT 0, -- preço fixo cobrado da clínica (= custo de laboratório) + garantia_meses INTEGER DEFAULT 12, + ativo BOOLEAN DEFAULT true, + ordem INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`, ]; for (const sql of migrations) { try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); } diff --git a/frontend/App.tsx b/frontend/App.tsx index 923bbf1..374d6b6 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -47,6 +47,7 @@ import { AdminGestaoTutoresView } from './views/AdminGestaoTutoresView.tsx'; import { WaitingInviteView } from './views/WaitingInviteView.tsx'; import { SuperAdminView } from './views/SuperAdminView.tsx'; import { DiretorioProfissionaisView } from './views/DiretorioProfissionaisView.tsx'; +import { ProteseView } from './views/ProteseView.tsx'; export type ViewKey = | 'landing' @@ -59,6 +60,7 @@ export type ViewKey = | 'financeiro' | 'comissoes' | 'glosas' + | 'proteses' | 'login' | 'update' | 'dentistas' @@ -147,6 +149,7 @@ const ROUTE_MAP: Record = { '/superadmin/notificacoes': 'superadmin-notificacoes', '/aguardando-convite': 'aguardando-convite', '/diretorio-profissionais': 'diretorio-profissionais', + '/proteses': 'proteses', }; const VIEW_TO_ROUTE: Record = { @@ -198,6 +201,7 @@ const VIEW_TO_ROUTE: Record = { 'superadmin-financeiro': '/superadmin/financeiro', 'superadmin-notificacoes': '/superadmin/notificacoes', 'diretorio-profissionais': '/diretorio-profissionais', + proteses: '/proteses', }; /** Resolve current URL to a ViewKey */ @@ -293,10 +297,10 @@ const App: React.FC = () => { const permissions: Record = { paciente: ['tratamentos', 'notificacoes', 'configuracoes'], - dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'], + 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', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], - funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], + protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', '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'], }; return permissions[role]?.includes(view) || false; @@ -455,6 +459,7 @@ const App: React.FC = () => { case 'relatorios': return ; case 'comissoes': return ; case 'glosas': return ; + case 'proteses': return ; case 'cadastro-dentista': return ; case 'configuracoes': return ; case 'orcamentos': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 5b3a284..0d20272 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -62,6 +62,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => { id: 'agenda', label: 'AGENDA', icon: CalendarIcon }, { id: 'pacientes', label: 'PACIENTES', icon: Users }, { id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList }, + { id: 'proteses', label: 'PRÓTESES', icon: FlaskConical }, { id: 'ortodontia', label: 'ORTODONTIA', icon: Activity }, { id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign }, { id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 }, @@ -122,13 +123,13 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => if (currentRole === 'donosala') return ['notificacoes', 'configuracoes'].includes(item.id); if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id); // Dentista: clínico odonto completo (ortodontia, tutoria orto). - if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id); + if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id); // Biomédico(a): seu próprio catálogo (especialidades/procedimentos de biomedicina); // 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', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id); - if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id); + if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', '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; }); diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 984865f..9215837 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -742,6 +742,79 @@ export const HybridBackend = { await apiFetch(`${API_URL}/glosas/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' }); }, + // ── Próteses · Ordem de Serviço (clínica ↔ laboratório/protético) ─────────── + getProteseLaboratorios: async () => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`); + return await res.json(); + }, + getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const qs = new URLSearchParams({ clinicaId }); + if (opts.status) qs.set('status', opts.status); + if (opts.paciente_id) qs.set('paciente_id', opts.paciente_id); + const res = await apiFetch(`${API_URL}/protese/os?${qs.toString()}`); + return await res.json(); + }, + getProteseBancada: async (status?: string) => { + const qs = status ? `?status=${encodeURIComponent(status)}` : ''; + const res = await apiFetch(`${API_URL}/protese/bancada${qs}`); + return await res.json(); + }, + getProteseOSDetalhe: async (id: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${id}`); + return await res.json(); + }, + createProteseOS: async (payload: any) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/os?clinicaId=${encodeURIComponent(clinicaId)}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar OS'); + return await res.json(); + }, + setProteseOSStatus: async (id: string, status: string, observacao?: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${id}/status`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status, observacao }) }); + 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) => { + const fd = new FormData(); + fd.append('foto', file); + 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(); + }, + deleteProteseOS: async (id: string) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' }); + }, + // Catálogo do protético (preço fixo + garantia) + getProteseProdutos: async () => { + const res = await apiFetch(`${API_URL}/protese/produtos`); + return await res.json(); + }, + getLaboratorioProdutos: async (proteticoId: string) => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/produtos?clinicaId=${encodeURIComponent(clinicaId)}`); + return await res.json(); + }, + createProteseProduto: async (payload: { nome: string; preco?: number; garantia_meses?: number }) => { + const res = await apiFetch(`${API_URL}/protese/produtos`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao criar produto'); + return await res.json(); + }, + updateProteseProduto: async (id: string, payload: any) => { + const res = await apiFetch(`${API_URL}/protese/produtos/${id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar produto'); + return await res.json(); + }, + deleteProteseProduto: async (id: string) => { + await apiFetch(`${API_URL}/protese/produtos/${id}`, { method: 'DELETE' }); + }, + // ── GTO (builder → finaliza → lança no financeiro) ────────────────────────── createGtoBuilder: async (payload: { pacienteId: string; dentistaId: string; credenciadaId: string }) => { const res = await apiFetch(`${API_URL}/gto-builder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx new file mode 100644 index 0000000..986c04d --- /dev/null +++ b/frontend/views/ProteseView.tsx @@ -0,0 +1,491 @@ +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 { HybridBackend } from '../services/backend.ts'; +import { useToast } from '../contexts/ToastContext.tsx'; +import { useConfirm } from '../contexts/ConfirmContext.tsx'; +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 STATUS_LABEL: Record = { + solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', + prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado', +}; +const STATUS_COR: Record = { + 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 LAB_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado']; + +const Badge: React.FC<{ s: string }> = ({ s }) => ( + {STATUS_LABEL[s] || s} +); + +// ── Modal: Nova OS (lado clínica) ─────────────────────────────────────────── +const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { + const toast = useToast(); + const [labs, setLabs] = useState([]); + const [dentistas, setDentistas] = useState([]); + const [busca, setBusca] = useState(''); + const [pacientes, setPacientes] = useState([]); + const [paciente, setPaciente] = useState(null); + const [produtos, setProdutos] = useState([]); + const [form, setForm] = useState({ protetico_id: '', produto_id: '', tipo: '', dentista_id: '', material: '', cor: '', dentes: '', prazo_entrega: '', valor: '', observacoes: '' }); + const [saving, setSaving] = useState(false); + const ws = HybridBackend.getActiveWorkspace(); + + useEffect(() => { + HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([])); + HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([])); + }, [ws?.id]); + + // Catálogo (preço fixo) do laboratório escolhido. + useEffect(() => { + if (!form.protetico_id) { setProdutos([]); return; } + HybridBackend.getLaboratorioProdutos(form.protetico_id) + .then(r => setProdutos(Array.isArray(r) ? r : [])) + .catch(() => setProdutos([])); + setForm((f: any) => ({ ...f, produto_id: '', tipo: '' })); + }, [form.protetico_id]); + + const produtoSel = produtos.find(p => p.id === form.produto_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.protetico_id) { toast.error('SELECIONE O LABORATÓRIO.'); return; } + if (!form.produto_id && !form.tipo) { toast.error('SELECIONE O PRODUTO.'); return; } + const dent = dentistas.find(d => d.id === form.dentista_id); + setSaving(true); + try { + await HybridBackend.createProteseOS({ + ...form, + valor: Number(form.valor) || 0, + paciente_id: paciente?.id || null, + paciente_nome: paciente?.nome || null, + dentista_nome: dent?.nome || null, + }); + toast.success('OS DE PRÓTESE CRIADA.'); + onSaved(); + } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } + finally { setSaving(false); } + }; + + return ( +
+
e.stopPropagation()}> +
+

Nova OS de Prótese

+ +
+
+ {/* Paciente */} +
+ + {paciente ? ( +
+ {paciente.nome} + +
+ ) : ( +
+ + 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 && ( +
+ {pacientes.slice(0, 8).map(p => ( + + ))} +
+ )} +
+ )} +
+ {/* Laboratório */} +
+ + + {labs.length === 0 &&

Nenhum protético com vínculo ou no diretório. Convide um na Equipe ou no marketplace de Profissionais.

} +
+ {/* Produto (catálogo do laboratório — preço fixo) */} +
+ + {form.protetico_id && produtos.length > 0 ? ( + + ) : ( + set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id} + className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" /> + )} + {produtoSel && ( +

Custo de laboratório {fmtBRL(produtoSel.preco)} · garantia {produtoSel.garantia_meses} meses

+ )} +
+ {/* Dentista */} +
+ + +
+ {/* Material + cor + dentes */} +
+
set('material', e.target.value)} placeholder="Zircônia..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
+
set('cor', e.target.value)} placeholder="A2..." className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
+
set('dentes', e.target.value)} placeholder="11,21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
+
+ {/* Prazo + valor cobrado do paciente */} +
+
set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
+
set('valor', e.target.value)} placeholder="0,00" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
+
+
+ +