diff --git a/backend/server.js b/backend/server.js index 5937f02..4bb7cde 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4032,8 +4032,9 @@ app.get('/api/protese/lab/clientes', authGuard, async (req, res) => { GROUP BY o.clinica_id`, [uid, labId]); const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)])); const origemDe = (tipo, role) => tipo === 'consultorio' ? 'Consultório' - : tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional') - : 'Clínica'; + : tipo === 'dentista' ? 'Dentista' + : tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional') + : 'Clínica'; res.json(rows.map(r => { const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0; return { @@ -4063,6 +4064,105 @@ app.get('/api/protese/lab/fechamento', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// FASE 6 — OS indicadas a mim ainda SEM laboratório vinculado. Só o dono as vê (fallback); +// ao importar, entram no lab e a equipe/técnicos passam a vê-las. +app.get('/api/protese/lab/os-indicadas', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const { rows } = await pool.query( + `SELECT o.id, o.tipo, o.status, o.custo, o.prazo_entrega, o.created_at, o.dentista_nome, o.paciente_nome, + c.nome_fantasia AS cliente_nome, c.tipo AS cliente_tipo + FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id + WHERE o.protetico_id=$1 AND o.laboratorio_id IS NULL AND o.deleted_at IS NULL + AND o.status NOT IN ('entregue','cancelado') + ORDER BY o.created_at DESC LIMIT 100`, [uid]); + res.json(rows.map(r => ({ ...r, custo: parseFloat(r.custo || 0) }))); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// FASE 6 — Importar/vincular a OS ao MEU laboratório (formaliza a entrada). +app.post('/api/protese/os/:id/vincular-lab', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const acc = await carregaOSComAcesso(req.params.id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + if (!acc.isLab) return res.status(403).json({ error: 'Apenas o protético destinatário importa a OS.' }); + if (acc.os.laboratorio_id) return res.status(409).json({ error: 'Esta OS já está vinculada a um laboratório.' }); + const labId = await labDoUsuario(uid); + if (!labId) return res.status(400).json({ error: 'Você não possui laboratório.' }); + const novoStatus = acc.os.status === 'solicitado' ? 'recebido' : acc.os.status; + await pool.query('UPDATE protese_os SET laboratorio_id=$1, status=$2 WHERE id=$3', [labId, novoStatus, acc.os.id]); + await logEventoOS(acc.os, uid, 'Trabalho importado para o laboratório', 'vinculo'); + if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Trabalho aceito pelo laboratório', `A OS (${acc.os.tipo}) foi importada pelo laboratório.`); + wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'vinculou' }); + res.json({ success: true, laboratorio_id: labId, status: novoStatus }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// FASE 6 — Solicitar pagamento (cobrar saldo): notifica o cliente e registra no histórico da OS. +app.post('/api/protese/os/:id/cobrar', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const acc = await carregaOSComAcesso(req.params.id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + if (!acc.isLab) return res.status(403).json({ error: 'Apenas o laboratório solicita pagamento.' }); + const { rows: pg } = await pool.query("SELECT COALESCE(SUM(valor),0) AS pago FROM protese_os_pagamento WHERE os_id=$1 AND lado='lab'", [acc.os.id]); + const pago = parseFloat(pg[0].pago || 0); + const saldo = Math.max(0, parseFloat(acc.os.custo || 0) - pago); + const valor = req.body?.valor != null ? Number(req.body.valor) : saldo; + if (!(valor > 0)) return res.status(400).json({ error: 'Nada a cobrar: o saldo já está quitado.' }); + const brl = valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); + const obs = (req.body?.observacao || '').trim(); + await logEventoOS(acc.os, uid, `Pagamento solicitado ao cliente: ${brl}${obs ? ` — ${obs}` : ''}`, 'cobranca'); + if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Solicitação de pagamento', `O laboratório solicitou ${brl} referente à OS (${acc.os.tipo}).${obs ? ` ${obs}` : ''}`); + wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'cobranca' }); + res.json({ success: true, valor, saldo }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// FASE 6 — Dar entrada em OS pelo lado do LABORATÓRIO (trabalho recebido fora do app). +// Cliente: clínica existente (cliente_id) OU cliente avulso (cliente_nome + cliente_tipo). +app.post('/api/protese/lab/os', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const labId = await labDoProtetico(uid); + if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório dá entrada em OS.' }); + const b = req.body || {}; + // Produto do catálogo (preço fixo) ou tipo livre + custo. + let tipo = (b.tipo || '').trim() || 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, uid]); + if (!pr.length) return res.status(404).json({ error: 'Produto não pertence ao seu 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: 'Informe o produto ou o tipo de prótese.' }); + // Cliente: existente (valida acesso ao lab) ou avulso (materializa registro mínimo, owner nulo). + let clienteId = b.cliente_id || null; + if (clienteId) { + const { rows: cl } = await pool.query('SELECT id FROM clinicas WHERE id=$1', [clienteId]); + if (!cl.length) return res.status(404).json({ error: 'Cliente não encontrado.' }); + } else { + const nome = (b.cliente_nome || '').trim(); + if (!nome) return res.status(400).json({ error: 'Informe o cliente (existente ou avulso).' }); + const tipoCli = ['clinica', 'consultorio', 'dentista'].includes(b.cliente_tipo) ? b.cliente_tipo : 'clinica'; + clienteId = `cliext_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query('INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,NULL)', [clienteId, nome.toUpperCase(), tipoCli]); + } + const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + const status = b.local_atual === 'laboratorio' ? 'recebido' : 'recebido'; // já está com o lab + await pool.query( + `INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_nome, dentista_nome, tipo, produto_id, tipo_os, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, local_atual, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'nova',$10,$11,$12,$13,$14,$15,0,$16,$17,'laboratorio',$18)`, + [id, clienteId, uid, labId, (await actorNome(uid)) || null, b.paciente_nome || null, b.dentista_nome || null, + tipo, produtoId, garantiaMeses, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null, + b.prazo_entrega || null, custo, status, uid]); + await pool.query( + `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'recebido',$4,$5,$6)`, + [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, clienteId, 'Entrada registrada pelo laboratório', uid, await actorNome(uid)]); + res.json({ success: true, id, cliente_id: clienteId }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab'). app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => { try { diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 134a811..e18213a 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1099,6 +1099,25 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/fechamento`); return await res.json().catch(() => []); }, + // Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório. + getLabOsIndicadas: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`); + return await res.json().catch(() => []); + }, + vincularOsLab: async (osId: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' }); + return await res.json().catch(() => ({ error: 'falha' })); + }, + // Fase 6: solicitar pagamento (cobrar saldo) ao cliente. + cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return await res.json().catch(() => ({ error: 'falha' })); + }, + // Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso). + criarLabOs: async (body: any) => { + const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + return await res.json().catch(() => ({ error: 'falha' })); + }, // Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos getLabDados: async (): Promise => { const res = await apiFetch(`${API_URL}/protese/lab`); diff --git a/frontend/views/LabHomeView.tsx b/frontend/views/LabHomeView.tsx index 1b89852..28c0106 100644 --- a/frontend/views/LabHomeView.tsx +++ b/frontend/views/LabHomeView.tsx @@ -1,28 +1,45 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-react'; +import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { PageHeader } from '../components/PageHeader.tsx'; +import { useToast } from '../contexts/ToastContext.tsx'; const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); +const CLI_TIPO = [ + { v: 'clinica', label: 'Clínica', icon: Building2 }, + { v: 'consultorio', label: 'Consultório', icon: Briefcase }, + { v: 'dentista', label: 'Dentista', icon: Stethoscope }, +]; // Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'. // Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab. export const LabHomeView: React.FC = () => { const ws: any = HybridBackend.getActiveWorkspace(); + const toast = useToast(); const [os, setOs] = useState([]); const [fin, setFin] = useState([]); + const [indicadas, setIndicadas] = useState([]); const [loading, setLoading] = useState(false); + const [showEntrada, setShowEntrada] = useState(false); + const [importando, setImportando] = useState(null); const carregar = useCallback(async () => { setLoading(true); try { - const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento()]); + const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]); setOs(Array.isArray(b) ? b : []); setFin(Array.isArray(f) ? f : []); + setIndicadas(Array.isArray(ind) ? ind : []); } catch { /* silent */ } finally { setLoading(false); } }, []); useEffect(() => { carregar(); }, [carregar]); + const importar = async (osId: string) => { + setImportando(osId); + try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); } + }; + const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado'); const atrasadas = os.filter(o => o.atrasada).length; const ymMes = new Date().toISOString().slice(0, 7); @@ -47,6 +64,9 @@ export const LabHomeView: React.FC = () => { return (
+ @@ -69,6 +89,31 @@ export const LabHomeView: React.FC = () => {
+ {/* OS indicadas ao laboratório aguardando importação */} + {indicadas.length > 0 && ( +
+
+ +

OS indicadas a você ({indicadas.length})

+ Importe para entrar na produção e financeiro +
+
+ {indicadas.map(o => ( +
+
+

{o.tipo}

+

{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}

+
+ {fmtBRL(o.custo)} + +
+ ))} +
+
+ )} + {/* Fila por status */}
@@ -117,6 +162,101 @@ export const LabHomeView: React.FC = () => {
)} + + {showEntrada && setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />} +
+ ); +}; + +// ── Modal: dar entrada em OS pelo lado do laboratório ──────────────────────── +const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => { + const toast = useToast(); + const [clientes, setClientes] = useState([]); + const [produtos, setProdutos] = useState([]); + const [busy, setBusy] = useState(false); + // origem do cliente: existente ou avulso + const [clienteId, setClienteId] = useState(''); // '' = avulso + const [cliNome, setCliNome] = useState(''); + const [cliTipo, setCliTipo] = useState('clinica'); + const [produtoId, setProdutoId] = useState(''); // '' = tipo livre + const [tipo, setTipo] = useState(''); + const [custo, setCusto] = useState(''); + const [prazo, setPrazo] = useState(''); + const [paciente, setPaciente] = useState(''); + const [obs, setObs] = useState(''); + + useEffect(() => { + HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {}); + HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {}); + }, []); + + const salvar = async () => { + if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; } + if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; } + setBusy(true); + try { + const body: any = { paciente_nome: paciente.trim() || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined }; + if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; } + if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; } + const r = await HybridBackend.criarLabOs(body); + if (r?.error) throw new Error(r.error); + toast.success('ENTRADA REGISTRADA.'); + onSaved(); + } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + + const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide'; + const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400'; + + return ( +
+
e.stopPropagation()}> +
+

Dar entrada em OS

+ +
+
+ {/* Cliente */} +
+ + + {!clienteId && ( +
+ setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} /> +
+ {CLI_TIPO.map(t => { const I = t.icon; return ( + + ); })} +
+
+ )} +
+ {/* Produto */} +
+ + + {!produtoId && ( +
+ setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} /> + setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} /> +
+ )} +
+
+
setPrazo(e.target.value)} type="date" className={inp} />
+
setPaciente(e.target.value)} placeholder="Nome" className={inp} />
+
+