feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório
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 <noreply@anthropic.com>
This commit is contained in:
+117
-9
@@ -3976,8 +3976,10 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
|||||||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
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: 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: 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 FROM protese_os_componente 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, 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: 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: 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.
|
// 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,
|
componentes,
|
||||||
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||||||
custodia,
|
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 }); }
|
} 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 id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const nome = await actorNome(uid);
|
const nome = await actorNome(uid);
|
||||||
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
|
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
|
||||||
|
const direcao = b.direcao === 'devolvido' ? 'devolvido' : 'enviado';
|
||||||
await pool.query(
|
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)`,
|
`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]);
|
[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 enviado: ${qtd}× ${String(b.nome).trim()}`, 'componente');
|
await logEventoOS(acc.os, uid, `Componente ${direcao}: ${qtd}× ${String(b.nome).trim()}`, 'componente');
|
||||||
res.json({ success: true, id });
|
res.json({ success: true, id });
|
||||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
} 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 }); }
|
} 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).
|
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||||
const novo = req.body?.status;
|
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 uid = req.authUser.userId;
|
||||||
const nome = await actorNome(uid);
|
const nome = await actorNome(uid);
|
||||||
const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null;
|
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)',
|
const ocorrenciaId = req.body?.ocorrencia_id || null; // foto de evidência de uma ocorrência
|
||||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda]);
|
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).
|
// Anexar foto entra no histórico (rastreabilidade: quem anexou e quando).
|
||||||
await pool.query(
|
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)`,
|
`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, legenda ? `Foto anexada: ${legenda}` : 'Foto anexada', uid, nome]);
|
[`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)}` });
|
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 }); }
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
});
|
});
|
||||||
@@ -5987,6 +6068,33 @@ async function runMigrations() {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
|
`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 (
|
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||||
|
|||||||
@@ -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.
|
`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.
|
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
|
||||||
|
|
||||||
### FASE 2 — Rastreabilidade & Ocorrências
|
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
|
||||||
Componentes recebidos/devolvidos/conferidos; tabela de ocorrências; evidências; contraditório.
|
- **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
|
### FASE 3 — Operação multi-clínica
|
||||||
Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo.
|
Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo.
|
||||||
|
|||||||
@@ -778,21 +778,44 @@ export const HybridBackend = {
|
|||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||||
return await res.json();
|
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();
|
const fd = new FormData();
|
||||||
fd.append('foto', file);
|
fd.append('foto', file);
|
||||||
if (legenda) fd.append('legenda', legenda);
|
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 });
|
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');
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
|
||||||
return await res.json();
|
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) => {
|
deleteProteseFoto: async (fotoId: string) => {
|
||||||
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
|
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');
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
},
|
},
|
||||||
// Componentes enviados ao laboratório
|
// 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 }) => {
|
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) });
|
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');
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [os, setOs] = useState<any>(null);
|
const [os, setOs] = useState<any>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
const carregar = useCallback(() => {
|
||||||
HybridBackend.getProteseOSDetalhe(id).then(setOs).catch(() => toast.error('ERRO AO CARREGAR.'));
|
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: 'etapas', label: 'Etapas' },
|
||||||
{ id: 'itens', label: 'Itens & Valores' },
|
{ id: 'itens', label: 'Itens & Valores' },
|
||||||
{ id: 'monitor', label: 'Monitoramento' },
|
{ id: 'monitor', label: 'Monitoramento' },
|
||||||
|
{ id: 'ocorrencias', label: `Ocorrências${os.ocorrencias?.length ? ` (${os.ocorrencias.length})` : ''}` },
|
||||||
{ id: 'timeline', label: 'Linha do tempo' },
|
{ id: 'timeline', label: 'Linha do tempo' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -383,6 +384,7 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
|
{tab === 'monitor' && <MonitoramentoSecao os={os} podeEditar={!finalizado} busy={busy} onMover={moverCustodia} />}
|
||||||
|
{tab === 'ocorrencias' && <OcorrenciasSecao os={os} onChanged={carregar} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<Record<string, string>>({});
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><AlertTriangle size={12} /> Ocorrências</p>
|
||||||
|
{!finalizadoOS && <button onClick={() => setOpen(o => !o)} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase">+ Abrir</button>}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="bg-red-50 rounded-xl p-3 space-y-2">
|
||||||
|
<select value={cat} onChange={e => setCat(e.target.value)} className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs font-bold outline-none focus:border-red-300">
|
||||||
|
{Object.entries(OCOR_CATS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} placeholder="Descreva (ex.: enviado parafuso original Neodent CM; devolvido com sinais de uso incompatíveis)." className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300 resize-none" />
|
||||||
|
<input value={resp} onChange={e => setResp(e.target.value)} placeholder="Responsável apontado (ex.: Dr. João / Laboratório)" className="w-full p-2 bg-white border border-red-100 rounded-lg text-xs outline-none focus:border-red-300" />
|
||||||
|
<button disabled={busy} onClick={abrir} className="w-full py-2 rounded-lg bg-red-600 text-white text-[11px] font-black uppercase disabled:opacity-50">Abrir ocorrência</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(os.ocorrencias || []).map((o: any) => (
|
||||||
|
<div key={o.id} className="border border-gray-100 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${OCOR_STATUS_COR[o.status] || 'bg-gray-100 text-gray-500'}`}>{(o.status || '').replace('_', ' ')}</span>
|
||||||
|
<span className="text-xs font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
|
||||||
|
<span className="text-[10px] text-gray-400">{dataBR(o.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">{o.descricao}</p>
|
||||||
|
<p className="text-[10px] text-gray-400">Aberta por {o.autor_nome}{o.responsavel ? ` · responsável: ${o.responsavel}` : ''}</p>
|
||||||
|
{o.resposta && <div className="bg-gray-50 rounded-lg p-2 text-xs"><span className="font-black text-gray-500 uppercase text-[9px]">Resposta ({o.resposta_nome}):</span> {o.resposta}</div>}
|
||||||
|
{o.fotos?.length > 0 && <div className="flex gap-1.5 flex-wrap">{o.fotos.map((f: any) => <a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-12 h-12 object-cover rounded border border-gray-100" /></a>)}</div>}
|
||||||
|
{o.status !== 'resolvida' && (
|
||||||
|
<div className="space-y-1.5 pt-1 border-t border-gray-50">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input value={respostas[o.id] || ''} onChange={e => setRespostas(s => ({ ...s, [o.id]: e.target.value }))} placeholder="Responder (contraditório)..." className="flex-1 p-1.5 bg-gray-50 border border-gray-100 rounded text-xs outline-none focus:border-teal-400" />
|
||||||
|
<button disabled={busy} onClick={() => responder(o.id)} className="px-2 rounded bg-gray-700 text-white text-[10px] font-black uppercase disabled:opacity-50">Responder</button>
|
||||||
|
<label className="px-2 py-1.5 rounded border border-gray-200 text-gray-500 cursor-pointer flex items-center"><ImagePlus size={12} /><input type="file" accept="image/*" className="hidden" onChange={e => anexar(o.id, e)} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button disabled={busy} onClick={() => mudar(o.id, 'em_analise')} className="flex-1 py-1 rounded bg-blue-100 text-blue-700 text-[9px] font-black uppercase disabled:opacity-50">Em análise</button>
|
||||||
|
<button disabled={busy} onClick={() => mudar(o.id, 'contestada')} className="flex-1 py-1 rounded bg-orange-100 text-orange-700 text-[9px] font-black uppercase disabled:opacity-50">Contestar</button>
|
||||||
|
<button disabled={busy} onClick={() => mudar(o.id, 'resolvida')} className="flex-1 py-1 rounded bg-green-600 text-white text-[9px] font-black uppercase disabled:opacity-50">Resolver</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{o.status === 'resolvida' && o.resolved_at && <p className="text-[9px] text-green-600 font-black uppercase">Resolvida em {dataBR(o.resolved_at)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(os.ocorrencias || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhuma ocorrência registrada.</p>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-gray-300 uppercase font-bold">Só ocorrências resolvidas pesarão em indicadores (futuro).</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
// ── Seção: Monitoramento de custódia (onde o trabalho está) ─────────────────
|
||||||
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => {
|
||||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||||
@@ -521,36 +613,52 @@ const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
|
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
|
||||||
|
const CONF_COR: Record<string, string> = { pendente: 'bg-gray-100 text-gray-500', ok: 'bg-green-100 text-green-700', divergente: 'bg-amber-100 text-amber-700', ausente: 'bg-red-100 text-red-600' };
|
||||||
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [nome, setNome] = useState('');
|
const [nome, setNome] = useState('');
|
||||||
const [qtd, setQtd] = useState('1');
|
const [qtd, setQtd] = useState('1');
|
||||||
|
const [direcao, setDirecao] = useState<'enviado' | 'devolvido'>('enviado');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
|
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1 }); setNome(''); setQtd('1'); onChanged(); }
|
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1, direcao }); setNome(''); setQtd('1'); onChanged(); }
|
||||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
const del = async (id: string) => { try { await HybridBackend.deleteProteseComponente(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||||
|
const conferir = async (id: string, st: string) => { try { await HybridBackend.conferirProteseComponente(id, st); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Componentes enviados</p>
|
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Package size={12} /> Rastreabilidade de componentes</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{(os.componentes || []).map((c: any) => (
|
{(os.componentes || []).map((c: any) => (
|
||||||
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
<div key={c.id} className="text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
|
||||||
<span className="font-black text-gray-700">{c.quantidade}×</span>
|
<span className="font-black text-gray-700">{c.quantidade}×</span>
|
||||||
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
|
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
|
||||||
<span className="text-[10px] text-gray-400">{c.enviado_nome} · {dataBR(c.created_at)}</span>
|
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${CONF_COR[c.status_conferencia] || CONF_COR.pendente}`}>{c.status_conferencia || 'pendente'}</span>
|
||||||
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-[9px] text-gray-400 flex-1 truncate">{c.enviado_nome} · {dataBR(c.created_at)}{c.conferido_nome ? ` · conferido por ${c.conferido_nome}` : ''}</span>
|
||||||
|
{podeEditar && ['ok', 'divergente', 'ausente'].map(st => (
|
||||||
|
<button key={st} onClick={() => conferir(c.id, st)} className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase border ${c.status_conferencia === st ? CONF_COR[st] + ' border-transparent' : 'border-gray-200 text-gray-400 hover:bg-white'}`}>{st}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
|
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
|
||||||
</div>
|
</div>
|
||||||
{podeEditar && (
|
{podeEditar && (
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-14 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
|
<select value={direcao} onChange={e => setDirecao(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] font-bold outline-none focus:border-teal-400">
|
||||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Ex.: Implante, pilar, modelo..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
<option value="enviado">Enviado</option>
|
||||||
|
<option value="devolvido">Devolvido</option>
|
||||||
|
</select>
|
||||||
|
<input value={qtd} onChange={e => setQtd(e.target.value)} type="number" min="1" className="w-12 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold text-center outline-none focus:border-teal-400" />
|
||||||
|
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Implante, pilar, parafuso..." className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||||
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
<button disabled={busy} onClick={add} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={13} /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user