From 3ffaff9d7d54b1d1217faccb29f2da0d8b3d5bb0 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 04:53:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Fase=202=20=E2=80=94=20rastrea?= =?UTF-8?q?bilidade=20de=20componentes=20+=20ocorr=C3=AAncias=20com=20cont?= =?UTF-8?q?radit=C3=B3rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provedor de Prótese, Fase 2 (doc/PROVEDOR-PROTESE-DECISAO.md): - Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir. UI: badges de direção e conferência + botões inline na aba Itens & Valores. - Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada, retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id). - Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final. Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba "Ocorrências" (com contador) no modal da OS. - Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5). Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 126 ++++++++++++++++++++++++++++--- doc/PROVEDOR-PROTESE-DECISAO.md | 12 ++- frontend/services/backend.ts | 29 +++++++- frontend/views/ProteseView.tsx | 128 +++++++++++++++++++++++++++++--- 4 files changed, 271 insertions(+), 24 deletions(-) diff --git a/backend/server.js b/backend/server.js index 69186c5..d2e2fd3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3976,8 +3976,10 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { 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, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]); - const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id]); + const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at', [req.params.id]); + const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at, COALESCE(direcao, $2) AS direcao, COALESCE(status_conferencia, $3) AS status_conferencia, conferido_nome, conferido_em FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id, 'enviado', 'pendente']); + const { rows: ocorrencias } = await pool.query('SELECT id, componente_id, categoria, descricao, responsavel, resposta, resposta_nome, status, autor_nome, created_at, resolved_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at', [req.params.id]); + const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [req.params.id]); 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]); // Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve. @@ -3993,6 +3995,10 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => { componentes, pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })), custodia, + 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)}` })), + })), }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -4038,10 +4044,11 @@ app.post('/api/protese/os/:id/componentes', authGuard, async (req, res) => { const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const nome = await actorNome(uid); const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1); + const direcao = b.direcao === 'devolvido' ? 'devolvido' : 'enviado'; await pool.query( - `INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome]); - await logEventoOS(acc.os, uid, `Componente enviado: ${qtd}× ${String(b.nome).trim()}`, 'componente'); + `INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome, direcao) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome, direcao]); + await logEventoOS(acc.os, uid, `Componente ${direcao}: ${qtd}× ${String(b.nome).trim()}`, 'componente'); res.json({ success: true, id }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -4177,6 +4184,79 @@ app.post('/api/protese/os/:id/trocar', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// ── FASE 2: rastreabilidade (conferência) + ocorrências (contraditório) ────── +const OCORRENCIA_CATS = ['nao_devolvido', 'danificado', 'incompativel', 'parafuso_substituido', 'parafuso_desgastado', 'peca_incorreta', 'ajuste_inadequado', 'falha_fabricacao', 'retrabalho', 'outro']; + +// Conferir um componente (ok | divergente | ausente | pendente). +app.post('/api/protese/os/componentes/:id/conferir', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' }); + const acc = await carregaOSComAcesso(rows[0].os_id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + const st = ['ok', 'divergente', 'ausente', 'pendente'].includes(req.body?.status_conferencia) ? req.body.status_conferencia : null; + if (!st) return res.status(400).json({ error: 'status_conferencia inválido.' }); + const nome = await actorNome(uid); + await pool.query('UPDATE protese_os_componente SET status_conferencia=$1, conferido_nome=$2, conferido_em=NOW() WHERE id=$3', [st, nome, req.params.id]); + await logEventoOS(acc.os, uid, `Componente conferido (${st}): ${rows[0].nome}`, 'componente'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Abrir ocorrência. +app.post('/api/protese/os/:id/ocorrencias', 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 b = req.body || {}; + if (!OCORRENCIA_CATS.includes(b.categoria)) return res.status(400).json({ error: 'Categoria inválida.' }); + if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva a ocorrência.' }); + const id = `poco_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const nome = await actorNome(uid); + await pool.query( + `INSERT INTO protese_os_ocorrencia (id, os_id, clinica_id, componente_id, categoria, descricao, responsavel, status, autor_id, autor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,'aberta',$8,$9)`, + [id, acc.os.id, acc.os.clinica_id, b.componente_id || null, b.categoria, String(b.descricao).trim(), b.responsavel || null, uid, nome]); + await logEventoOS(acc.os, uid, `Ocorrência aberta: ${b.categoria}`, 'ocorrencia'); + res.json({ success: true, id }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Responder ocorrência (contraditório) → 'respondida'. +app.post('/api/protese/os/ocorrencias/:id/resposta', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' }); + const acc = await carregaOSComAcesso(rows[0].os_id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida.' }); + if (!req.body?.resposta || !String(req.body.resposta).trim()) return res.status(400).json({ error: 'Escreva a resposta.' }); + const nome = await actorNome(uid); + await pool.query("UPDATE protese_os_ocorrencia SET resposta=$1, resposta_nome=$2, status=CASE WHEN status='aberta' THEN 'respondida' ELSE status END WHERE id=$3", [String(req.body.resposta).trim(), nome, req.params.id]); + await logEventoOS(acc.os, uid, `Ocorrência respondida: ${rows[0].categoria}`, 'ocorrencia'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Mudar status da ocorrência (em_analise | resolvida | contestada). 'resolvida' é final. +app.post('/api/protese/os/ocorrencias/:id/status', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const novo = req.body?.status; + if (!['em_analise', 'resolvida', 'contestada'].includes(novo)) return res.status(400).json({ error: 'Status inválido.' }); + const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' }); + const acc = await carregaOSComAcesso(rows[0].os_id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida (final).' }); + await pool.query("UPDATE protese_os_ocorrencia SET status=$1, resolved_at=CASE WHEN $1='resolvida' THEN NOW() ELSE resolved_at END WHERE id=$2", [novo, req.params.id]); + await logEventoOS(acc.os, uid, `Ocorrência ${novo}: ${rows[0].categoria}`, 'ocorrencia'); + res.json({ success: true, status: novo }); + } 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; @@ -4257,12 +4337,13 @@ app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async const uid = req.authUser.userId; const nome = await actorNome(uid); const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null; - await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', - [fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda]); + const ocorrenciaId = req.body?.ocorrencia_id || null; // foto de evidência de uma ocorrência + await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda, ocorrencia_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)', + [fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda, ocorrenciaId]); // Anexar foto entra no histórico (rastreabilidade: quem anexou e quando). await pool.query( - `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'foto',$4,$5,$6)`, - [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, legenda ? `Foto anexada: ${legenda}` : 'Foto anexada', uid, nome]); + `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, ocorrenciaId ? 'ocorrencia' : 'foto', ocorrenciaId ? 'Evidência anexada à ocorrência' : (legenda ? `Foto anexada: ${legenda}` : 'Foto anexada'), uid, nome]); 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 }); } }); @@ -5987,6 +6068,33 @@ async function runMigrations() { created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`, + // ── FASE 2: Rastreabilidade de componentes + Ocorrências (com contraditório) ── + // Componentes ganham direção (enviado clínica→lab | devolvido lab→clínica) e conferência. + `ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS direcao TEXT DEFAULT 'enviado'`, + `ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS status_conferencia TEXT DEFAULT 'pendente'`, // pendente|ok|divergente|ausente + `ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_nome TEXT`, + `ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_em TIMESTAMPTZ`, + // Ocorrências técnicas (parafuso trocado, peça danificada, retrabalho…). Contraditório: + // aberta → respondida → em_analise → resolvida | contestada. Só resolvidas pesam no score (Fase 5). + `CREATE TABLE IF NOT EXISTS protese_os_ocorrencia ( + id TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + clinica_id TEXT, + componente_id TEXT, + categoria TEXT NOT NULL, + descricao TEXT, + responsavel TEXT, + resposta TEXT, + resposta_nome TEXT, + status TEXT DEFAULT 'aberta', + autor_id TEXT, + autor_nome TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`, + // Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id). + `ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`, `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/PROVEDOR-PROTESE-DECISAO.md b/doc/PROVEDOR-PROTESE-DECISAO.md index 05026d9..1e57a15 100644 --- a/doc/PROVEDOR-PROTESE-DECISAO.md +++ b/doc/PROVEDOR-PROTESE-DECISAO.md @@ -61,8 +61,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado. `paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF. - Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo. -### FASE 2 — Rastreabilidade & Ocorrências -Componentes recebidos/devolvidos/conferidos; tabela de ocorrências; evidências; contraditório. +### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun) +- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/ + ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`. +- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status. + Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia). +- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final + (409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`. +- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes. +- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o + contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5. ### FASE 3 — Operação multi-clínica Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo. diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 1a44d55..7bb2eab 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -778,21 +778,44 @@ export const HybridBackend = { 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, legenda?: string) => { + uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => { const fd = new FormData(); fd.append('foto', file); if (legenda) fd.append('legenda', legenda); + if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId); 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(); }, + // Conferência de componente (status: ok | divergente | ausente | pendente) + conferirProteseComponente: async (compId: string, status_conferencia: string) => { + const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir'); + return await res.json(); + }, + // Ocorrências (contraditório) + abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência'); + return await res.json(); + }, + responderProteseOcorrencia: async (ocId: string, resposta: string) => { + const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder'); + return await res.json(); + }, + statusProteseOcorrencia: async (ocId: string, status: string) => { + const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/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 mudar status'); + return await res.json(); + }, deleteProteseFoto: async (fotoId: string) => { const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto'); return await res.json(); }, - // Componentes enviados ao laboratório - addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string }) => { + // Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica) + addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => { const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente'); return await res.json(); diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index b01a12a..6f4ccf0 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -180,7 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose const confirm = useConfirm(); const [os, setOs] = useState(null); const [busy, setBusy] = useState(false); - const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor'>('etapas'); + const [tab, setTab] = useState<'etapas' | 'timeline' | 'itens' | 'monitor' | 'ocorrencias'>('etapas'); const carregar = useCallback(() => { HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.')); @@ -268,6 +268,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose { id: 'etapas', label: 'Etapas' }, { id: 'itens', label: 'Itens & Valores' }, { id: 'monitor', label: 'Monitoramento' }, + { id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` }, { id: 'timeline', label: 'Linha do tempo' }, ] as const; @@ -383,6 +384,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose )} {tab === 'monitor' && } + {tab === 'ocorrencias' && } @@ -458,6 +460,96 @@ const AjustesSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; onChanged: ); }; +// ── Seção: Ocorrências (rastreabilidade técnica + contraditório) ──────────── +const OCOR_CATS: Record = { + nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível', + parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça recebida incorreta', + ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho solicitado', outro: 'Outro', +}; +const OCOR_STATUS_COR: Record = { + aberta: 'bg-red-100 text-red-600', respondida: 'bg-amber-100 text-amber-700', em_analise: 'bg-blue-100 text-blue-700', + resolvida: 'bg-green-100 text-green-700', contestada: 'bg-orange-100 text-orange-700', +}; +const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, onChanged }) => { + const toast = useToast(); + const [open, setOpen] = useState(false); + const [cat, setCat] = useState('danificado'); + const [desc, setDesc] = useState(''); + const [resp, setResp] = useState(''); + const [busy, setBusy] = useState(false); + const [respostas, setRespostas] = useState>({}); + const finalizadoOS = os.status === 'entregue' || os.status === 'cancelado'; + const err = (e: any) => toast.error((e.message || 'ERRO').toUpperCase()); + + const abrir = async () => { + if (!desc.trim()) { toast.error('DESCREVA A OCORRÊNCIA.'); return; } + setBusy(true); + try { await HybridBackend.abrirProteseOcorrencia(os.id, { categoria: cat, descricao: desc.trim(), responsavel: resp.trim() || undefined }); toast.success('OCORRÊNCIA ABERTA.'); setDesc(''); setResp(''); setOpen(false); onChanged(); } + catch (e) { err(e); } finally { setBusy(false); } + }; + const responder = async (id: string) => { + const t = (respostas[id] || '').trim(); if (!t) { toast.error('ESCREVA A RESPOSTA.'); return; } + setBusy(true); + try { await HybridBackend.responderProteseOcorrencia(id, t); setRespostas(s => ({ ...s, [id]: '' })); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } + }; + const mudar = async (id: string, st: string) => { setBusy(true); try { await HybridBackend.statusProteseOcorrencia(id, st); onChanged(); } catch (e) { err(e); } finally { setBusy(false); } }; + const anexar = async (id: string, e: React.ChangeEvent) => { + const f = e.target.files?.[0]; if (!f) return; setBusy(true); + try { await HybridBackend.uploadProteseFoto(os.id, f, undefined, id); onChanged(); } catch (er) { err(er); } finally { setBusy(false); e.target.value = ''; } + }; + + return ( +
+
+

Ocorrências

+ {!finalizadoOS && } +
+ {open && ( +
+ +