From dc5a2d89ae6a54a21ce1e0de04b6cc6bc3ff0d17 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 18:11:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20agenda=20&=20coleta=20do=20lab?= =?UTF-8?q?orat=C3=B3rio=20(integrada=20=C3=A0=20cust=C3=B3dia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB): - protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora, endereço, responsável, status. Ligado à OS. - POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica), POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[]. - UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na aba Monitoramento da OS (clínica e lab). Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 83 +++++++++++++++++++++++++++++ doc/AGENDA-COLETA-LAB.md | 47 +++++++++++++++++ frontend/App.tsx | 9 +++- frontend/components/Sidebar.tsx | 5 +- frontend/services/backend.ts | 15 ++++++ frontend/views/LabAgendaView.tsx | 89 ++++++++++++++++++++++++++++++++ frontend/views/ProteseView.tsx | 42 +++++++++++++++ 7 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 doc/AGENDA-COLETA-LAB.md create mode 100644 frontend/views/LabAgendaView.tsx diff --git a/backend/server.js b/backend/server.js index df470c8..5de3479 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4315,6 +4315,69 @@ app.post('/api/protese/os/:id/avaliar', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// ── Agenda & Coleta do laboratório ────────────────────────────────────────── +app.post('/api/protese/os/:id/coleta', 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 }); + const tipo = ['coleta', 'entrega'].includes(req.body?.tipo) ? req.body.tipo : null; + if (!tipo) return res.status(400).json({ error: "tipo deve ser 'coleta' ou 'entrega'." }); + const labId = acc.os.laboratorio_id || await labDoProtetico(acc.os.protetico_id); + const id = `pcol_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const b = req.body || {}; + await pool.query( + `INSERT INTO protese_coleta (id, os_id, clinica_id, laboratorio_id, protetico_id, tipo, data_hora, endereco, responsavel, observacao, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + [id, acc.os.id, acc.os.clinica_id, labId, acc.os.protetico_id, tipo, b.data_hora || null, b.endereco || null, b.responsavel || null, b.observacao || null, uid]); + const quando = b.data_hora ? new Date(b.data_hora).toLocaleString('pt-BR') : 'sem data'; + await logEventoOS(acc.os, uid, `${tipo === 'coleta' ? 'Coleta' : 'Entrega'} agendada (${quando})`, 'coleta'); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.get('/api/protese/lab/agenda', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const labId = await labDoUsuario(uid); + const { rows } = await pool.query( + `SELECT c.*, o.paciente_nome, o.tipo AS os_tipo, o.status AS os_status, + (SELECT nome_fantasia FROM clinicas WHERE id=c.clinica_id) AS clinica_nome + FROM protese_coleta c JOIN protese_os o ON o.id = c.os_id + WHERE (c.laboratorio_id=$2 OR c.protetico_id=$1) AND c.status <> 'cancelada' + ORDER BY c.status='realizada', c.data_hora ASC NULLS LAST LIMIT 200`, [uid, labId]); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.post('/api/protese/coleta/:id/status', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const { rows } = await pool.query('SELECT * FROM protese_coleta WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Agendamento não encontrado.' }); + const col = rows[0]; + const acc = await carregaOSComAcesso(col.os_id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + const novo = ['realizada', 'cancelada'].includes(req.body?.status) ? req.body.status : null; + if (!novo) return res.status(400).json({ error: 'Status inválido.' }); + if (col.status !== 'agendada') return res.status(409).json({ error: `Já ${col.status}.` }); + await pool.query('UPDATE protese_coleta SET status=$1 WHERE id=$2', [novo, col.id]); + if (novo === 'realizada') { + // Coleta → trabalho no laboratório; entrega → trabalho na clínica. Move a custódia. + const para = col.tipo === 'coleta' ? 'laboratorio' : 'clinica'; + const de = acc.os.local_atual || 'clinica'; + if (de !== para) { + const nome = await actorNome(uid); + await pool.query("INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + [`pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, uid, nome]); + await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]); + } + await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} realizada`, 'coleta'); + } else { + await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} cancelada`, 'coleta'); + } + res.json({ success: true, status: novo }); + } 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 { @@ -4427,6 +4490,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]); const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]); const { rows: aval } = await pool.query('SELECT estrelas, comentario, autor_nome FROM protese_avaliacao WHERE os_id=$1', [req.params.id]); + const { rows: coletas } = await pool.query('SELECT id, tipo, data_hora, endereco, responsavel, observacao, status FROM protese_coleta WHERE os_id=$1 ORDER BY created_at', [req.params.id]); // Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve. const soLab = isLab && !isClinic; const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos; @@ -4441,6 +4505,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })), custodia, avaliacao: aval[0] || null, + coletas, ocorrencias: ocorrencias.map(o => ({ ...o, fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })), @@ -6601,6 +6666,24 @@ async function runMigrations() { created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_protese_aval_prot ON protese_avaliacao (protetico_id)`, + // Agenda & Coleta do laboratório: coleta (lab busca) / entrega (lab leva). 'realizada' move a custódia. + `CREATE TABLE IF NOT EXISTS protese_coleta ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + clinica_id TEXT, + laboratorio_id TEXT, + protetico_id TEXT, + tipo TEXT NOT NULL, + data_hora TIMESTAMPTZ, + endereco TEXT, + responsavel TEXT, + observacao TEXT, + status TEXT DEFAULT 'agendada', + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`, + `CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`, `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/AGENDA-COLETA-LAB.md b/doc/AGENDA-COLETA-LAB.md new file mode 100644 index 0000000..0836e71 --- /dev/null +++ b/doc/AGENDA-COLETA-LAB.md @@ -0,0 +1,47 @@ +# Agenda & Coleta do laboratório + +> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de +> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse). + +## Conceito +- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório). +- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica). +- Cada agendamento tem data/hora, endereço, responsável, observação e status + (`agendada | realizada | cancelada`). Ligado a uma OS. +- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente + (coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado. + +## Modelo +```sql +CREATE TABLE protese_coleta ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + clinica_id TEXT, + laboratorio_id TEXT, + protetico_id TEXT, + tipo TEXT NOT NULL, -- coleta | entrega + data_hora TIMESTAMPTZ, + endereco TEXT, + responsavel TEXT, + observacao TEXT, + status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +## Endpoints +- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab). +- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo. +- `POST /api/protese/coleta/:id/status` — `realizada` (move a custódia) | `cancelada`. +- GET detalhe da OS passa a trazer `coletas[]`. + +## UI +- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas + agendadas + entregas previstas (prazo da OS), com ação "marcar realizada". +- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico). + +## Decisões +- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução. +- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia + e coleta em separado. diff --git a/frontend/App.tsx b/frontend/App.tsx index 536e1f9..4f3e711 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -32,6 +32,7 @@ import { ComissoesView } from './views/ComissoesView.tsx'; import { GlosasView } from './views/GlosasView.tsx'; import { SalaHomeView } from './views/SalaHomeView.tsx'; import { LabHomeView } from './views/LabHomeView.tsx'; +import { LabAgendaView } from './views/LabAgendaView.tsx'; import { LabClientesView } from './views/LabClientesView.tsx'; import { LabCotacoesView } from './views/LabCotacoesView.tsx'; import { LabEquipeView } from './views/LabEquipeView.tsx'; @@ -91,6 +92,7 @@ export type ViewKey = | 'sala-home' | 'lab-home' | 'lab-bancada' + | 'lab-agenda' | 'lab-clientes' | 'lab-financeiro' | 'lab-cotacoes' @@ -148,6 +150,7 @@ const ROUTE_MAP: Record = { '/sala-home': 'sala-home', '/lab-home': 'lab-home', '/lab-bancada': 'lab-bancada', + '/lab-agenda': 'lab-agenda', '/lab-clientes': 'lab-clientes', '/lab-financeiro': 'lab-financeiro', '/lab-cotacoes': 'lab-cotacoes', @@ -210,6 +213,7 @@ const VIEW_TO_ROUTE: Record = { 'sala-home': '/sala-home', 'lab-home': '/lab-home', 'lab-bancada': '/lab-bancada', + 'lab-agenda': '/lab-agenda', 'lab-clientes': '/lab-clientes', 'lab-financeiro': '/lab-financeiro', 'lab-cotacoes': '/lab-cotacoes', @@ -314,7 +318,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-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); + if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', '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. @@ -333,7 +337,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-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], + protetico: ['lab-home', 'lab-bancada', 'lab-agenda', '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'], }; @@ -508,6 +512,7 @@ const App: React.FC = () => { case 'sala-home': return ; case 'lab-home': return ; case 'lab-bancada': return ; + case 'lab-agenda': return ; case 'lab-clientes': return ; case 'lab-financeiro': return ; case 'lab-cotacoes': return ; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 04615fe..54a0c75 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -63,6 +63,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => { id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen }, { id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard }, { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, + { id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon }, { id: 'lab-clientes', label: 'CLIENTES', icon: Building2 }, { id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText }, { id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign }, @@ -105,7 +106,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-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio'; + if (['lab-home', 'lab-bancada', 'lab-agenda', '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.) @@ -118,7 +119,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-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id); + : ['lab-home', 'lab-bancada', 'lab-agenda', '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 4f013c3..4f86f05 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1124,6 +1124,21 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/indicadores`); return await res.json().catch(() => ({})); }, + // Agenda & Coleta do laboratório + agendarColeta: async (osId: string, body: { tipo: 'coleta' | 'entrega'; data_hora?: string; endereco?: string; responsavel?: string; observacao?: string }) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/coleta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao agendar'); + return await res.json(); + }, + getLabAgenda: async (): Promise => { + const res = await apiFetch(`${API_URL}/protese/lab/agenda`); + return await res.json().catch(() => []); + }, + statusColeta: async (coletaId: string, status: 'realizada' | 'cancelada') => { + const res = await apiFetch(`${API_URL}/protese/coleta/${coletaId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao atualizar'); + return await res.json(); + }, // Fatia 4: a clínica avalia o laboratório ao final da OS avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => { const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); diff --git a/frontend/views/LabAgendaView.tsx b/frontend/views/LabAgendaView.tsx new file mode 100644 index 0000000..3094609 --- /dev/null +++ b/frontend/views/LabAgendaView.tsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; +import { useToast } from '../contexts/ToastContext.tsx'; +import { PageHeader } from '../components/PageHeader.tsx'; + +const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }; +const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); }; + +// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas. +export const LabAgendaView: React.FC = () => { + const toast = useToast(); + const [lista, setLista] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(''); + + const carregar = useCallback(async () => { + setLoading(true); + try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); } + catch { /* silent */ } finally { setLoading(false); } + }, []); + useEffect(() => { carregar(); }, [carregar]); + + const acao = async (id: string, status: 'realizada' | 'cancelada') => { + setBusy(id); + try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); } + }; + + // agrupa por dia (data_hora) + const grupos: Record = {}; + for (const c of lista.filter(x => x.status === 'agendada')) { + const k = (c.data_hora || '').slice(0, 10) || 'sem-data'; + (grupos[k] = grupos[k] || []).push(c); + } + const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10); + + const Card: React.FC<{ c: any }> = ({ c }) => { + const coleta = c.tipo === 'coleta'; + return ( +
+
{coleta ? : }
+
+
+ {coleta ? 'Coleta' : 'Entrega'} + {dataHoraBR(c.data_hora)} +
+

{c.os_tipo} {c.paciente_nome ? · {c.paciente_nome} : ''}

+

{c.clinica_nome || 'Clínica'}

+ {c.endereco &&

{c.endereco}

} + {c.responsavel &&

{c.responsavel}

} + {c.status === 'agendada' && ( +
+ + +
+ )} +
+
+ ); + }; + + return ( +
+ + + + + {loading ?
+ : lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ?
Nenhuma coleta ou entrega agendada.
+ : ( +
+ {Object.keys(grupos).sort().map(dia => ( +
+

{dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}

+
{grupos[dia].map(c => )}
+
+ ))} + {realizadas.length > 0 && ( +
+

Realizadas (recentes)

+
{realizadas.map(c => )}
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 71a0597..08f3fab 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -387,6 +387,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose {tab === 'monitor' && (
+ {modo === 'clinica' && }
)} @@ -600,6 +601,47 @@ const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => { ); }; +// ── Seção: Agendar coleta / entrega (integra com a custódia) ──────────────── +const AgendaColetaSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => { + const toast = useToast(); + const [tipo, setTipo] = useState<'coleta' | 'entrega'>('coleta'); + const [data, setData] = useState(''); + const [resp, setResp] = useState(''); + const [busy, setBusy] = useState(false); + const add = async () => { + setBusy(true); + try { await HybridBackend.agendarColeta(os.id, { tipo, data_hora: data || undefined, responsavel: resp.trim() || undefined }); toast.success('AGENDADO.'); setData(''); setResp(''); onChanged(); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const marcar = async (id: string, status: 'realizada' | 'cancelada') => { try { await HybridBackend.statusColeta(id, status); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } }; + return ( +
+

Coleta / entrega

+ {(os.coletas || []).map((c: any) => ( +
+ {c.tipo} + {c.data_hora ? dataHoraBR(c.data_hora) : 'sem data'}{c.responsavel ? ` · ${c.responsavel}` : ''} + {c.status === 'agendada' + ? (podeEditar && ) + : {c.status}} +
+ ))} + {(os.coletas || []).length === 0 &&

Nenhuma coleta agendada.

} + {podeEditar && ( +
+ + setData(e.target.value)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] outline-none focus:border-teal-400" /> + setResp(e.target.value)} placeholder="Responsável" className="flex-1 min-w-[100px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" /> + +
+ )} +
+ ); +}; + // ── Seção: Avaliar o laboratório (após a entrega, lado clínica) ───────────── const AvaliacaoSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => { const toast = useToast();