feat(protese): profissionaliza a OS — etapas, fotos, componentes, pagamentos e auditoria

Reformula o modal de detalhe da OS de prótese, fechando os gaps de gestão:

- Etapas: stepper visual do fluxo (solicitado→…→entregue) com a etapa atual e datas.
- Fotos: agora anexáveis TAMBÉM no modo clínica (antes só na bancada — beco sem saída
  para entregar), com autoria (quem anexou), data e legenda; remover foto. Toda foto
  entra no histórico.
- Componentes enviados: nova tabela protese_os_componente — item, qtd, quem enviou e
  quando (ex.: implante, pilar, modelo). Add/remover.
- Valores recebidos (dois lados): nova tabela protese_os_pagamento — lado='clinica'
  (paciente→clínica) e lado='lab' (clínica→lab), com totais, forma, autor e data.
- Auditoria: histórico com data+HORA e autor; foto/componente/pagamento viram eventos
  rastreados. Helpers carregaOSComAcesso/logEventoOS reaproveitados nos sub-recursos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-23 23:41:19 +02:00
parent b80d061a6f
commit 623c9990d6
3 changed files with 366 additions and 39 deletions
+145 -3
View File
@@ -3973,15 +3973,115 @@ 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 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 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: 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]);
res.json({ res.json({
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0), ...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
eventos, eventos,
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })), fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
componentes,
pagamentos: pagamentos.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
}); });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// Carrega a OS e valida acesso (lab ou clínica de origem). Reuso nos sub-recursos da OS.
async function carregaOSComAcesso(osId, uid) {
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [osId]);
if (!rows.length) return { status: 404, error: 'OS não encontrada.' };
const os = rows[0];
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
if (!isLab && !isClinic) return { status: 403, error: 'Sem acesso a esta OS.' };
return { os, isLab, isClinic };
}
async function logEventoOS(os, uid, observacao, status = 'nota') {
await pool.query(
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, status, observacao, uid, await actorNome(uid)]);
}
// Remover foto (rastreado no histórico).
app.delete('/api/protese/os/fotos/:id', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_foto WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Foto não encontrada.' });
const foto = rows[0];
const acc = await carregaOSComAcesso(foto.os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
await pool.query('DELETE FROM protese_os_foto WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, 'Foto removida', 'foto');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Componentes/insumos enviados ao laboratório.
app.post('/api/protese/os/:id/componentes', 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 (!b.nome || !String(b.nome).trim()) return res.status(400).json({ error: 'Nome do componente é obrigatório.' });
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);
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');
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/protese/os/componentes/:id', 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 });
await pool.query('DELETE FROM protese_os_componente WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, `Componente removido: ${rows[0].nome}`, 'componente');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Pagamentos/recebimentos da OS (lado='lab' clínica→lab | lado='clinica' paciente→clínica).
app.post('/api/protese/os/:id/pagamentos', 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 || {};
const lado = ['lab', 'clinica'].includes(b.lado) ? b.lado : null;
if (!lado) return res.status(400).json({ error: "lado deve ser 'lab' ou 'clinica'." });
const valor = Number(b.valor);
if (!(valor > 0)) return res.status(400).json({ error: 'Valor deve ser maior que zero.' });
const id = `ppag_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid);
await pool.query(
`INSERT INTO protese_os_pagamento (id, os_id, clinica_id, lado, valor, forma, observacao, data, recebido_por, recebido_nome)
VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8::date,CURRENT_DATE),$9,$10)`,
[id, acc.os.id, acc.os.clinica_id, lado, valor, b.forma || null, b.observacao || null, b.data || null, uid, nome]);
const rotulo = lado === 'lab' ? 'pago ao laboratório' : 'recebido do paciente';
await logEventoOS(acc.os, uid, `Pagamento ${rotulo}: ${valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`, 'pagamento');
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Pagamento não encontrado.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
await pool.query('DELETE FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, 'Pagamento removido', 'pagamento');
res.json({ success: true });
} 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;
@@ -4057,8 +4157,15 @@ app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true }); await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer); await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', const uid = req.authUser.userId;
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]); 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]);
// 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]);
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 }); }
}); });
@@ -5732,6 +5839,41 @@ async function runMigrations() {
FROM clinicas c FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`, WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`, `CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
// ── Profissionalização da OS de prótese ────────────────────────────────────
// Fotos com autoria + legenda (quem anexou / o quê).
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_by TEXT`,
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_nome TEXT`,
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS legenda TEXT`,
// Componentes/insumos enviados ao laboratório (ex.: implante, pilar, modelo).
`CREATE TABLE IF NOT EXISTS protese_os_componente (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
nome TEXT NOT NULL,
quantidade INTEGER DEFAULT 1,
observacao TEXT,
enviado_por TEXT,
enviado_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_comp ON protese_os_componente (os_id, created_at)`,
// Pagamentos/recebimentos vinculados à OS (dois lados):
// lado='lab' → a clínica paga o laboratório (a-receber do lab)
// lado='clinica' → o paciente paga a clínica pela prótese
`CREATE TABLE IF NOT EXISTS protese_os_pagamento (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
lado TEXT NOT NULL,
valor NUMERIC DEFAULT 0,
forma TEXT,
observacao TEXT,
data DATE DEFAULT CURRENT_DATE,
recebido_por TEXT,
recebido_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
`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)
+29 -1
View File
@@ -778,13 +778,41 @@ 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) => { uploadProteseFoto: async (id: string, file: File, legenda?: string) => {
const fd = new FormData(); const fd = new FormData();
fd.append('foto', file); fd.append('foto', file);
if (legenda) fd.append('legenda', legenda);
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();
}, },
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 }) => {
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();
},
deleteProteseComponente: async (compId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
return await res.json();
},
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
return await res.json();
},
deleteProtesePagamento: async (pagId: string) => {
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
return await res.json();
},
deleteProteseOS: async (id: string) => { deleteProteseOS: async (id: string) => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' }); await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
+186 -29
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check } from 'lucide-react'; import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx'; import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx';
@@ -7,6 +7,11 @@ import { PageHeader } from '../components/PageHeader.tsx';
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/'); const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
const dataHoraBR = (d: string) => {
if (!d) return '';
const dt = new Date(d);
return isNaN(dt.getTime()) ? dataBR(d) : dt.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
@@ -222,54 +227,53 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
return ( return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}> <div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}> <div className="bg-white rounded-2xl w-full max-w-2xl max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white"> <div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
<div> <div>
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3> <h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FlaskConical size={16} className="text-violet-600" /> {os.tipo}</h3>
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p> <p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
</div> </div>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button> <button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-5">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge s={os.status} /> <Badge s={os.status} />
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>} {os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>} {os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
{os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> {dataBR(os.prazo_entrega)}</span>} {os.prazo_entrega && <span className="text-[10px] text-gray-400 flex items-center gap-1"><Clock size={11} /> Prazo {dataBR(os.prazo_entrega)}</span>}
</div> </div>
{/* Stepper de etapas */}
<EtapasStepper os={os} />
{os.garantia_ate && ( {os.garantia_ate && (
<div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold"> <div className="bg-emerald-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 font-bold">
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses) <ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-xs">
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>} {os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>} {os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>} {os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
{modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>} {modo === 'bancada' && Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Custo lab</span>{fmtBRL(os.custo)}</div>}
{modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor</span>{fmtBRL(os.valor)}</div>} {modo === 'clinica' && Number(os.valor) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor cobrado</span>{fmtBRL(os.valor)}</div>}
</div> </div>
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>} {os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
{/* Fotos */} <ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
{os.fotos?.length > 0 && ( <FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
<div className="flex gap-2 flex-wrap"> <PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
{os.fotos.map((f: any) => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-100" /></a>
))}
</div>
)}
{/* Timeline */} {/* Histórico (auditoria) */}
<div> <div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p> <p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><History size={12} /> Histórico</p>
<div className="space-y-2"> <div className="space-y-2.5">
{os.eventos?.map((ev: any, i: number) => ( {os.eventos?.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs"> <div key={i} className="flex items-start gap-2.5 text-xs">
<span className="w-2 h-2 rounded-full bg-violet-400 mt-1 shrink-0" /> <span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : 'bg-violet-400'}`} />
<div> <div className="min-w-0">
<span className="font-bold text-gray-700">{STATUS_LABEL[ev.status] || ev.observacao || ev.status}</span> <span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
<span className="text-gray-400"> · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''}</span> <span className="text-gray-400 block text-[11px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
</div> </div>
</div> </div>
))} ))}
@@ -281,18 +285,12 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{!finalizado && ( {!finalizado && (
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2"> <div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
{modo === 'bancada' ? ( {modo === 'bancada' ? (
<>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{LAB_FLUXO.map(s => ( {LAB_FLUXO.map(s => (
<button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)} <button key={s} disabled={busy || os.status === s} onClick={() => mudar(s)}
className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button> className={`px-3 py-2 rounded-xl text-xs font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-violet-600 text-white' : 'border border-violet-200 text-violet-700 hover:bg-violet-50'}`}>{STATUS_LABEL[s]}</button>
))} ))}
</div> </div>
<label className="flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer">
<ImagePlus size={15} /> Anexar foto do trabalho
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</>
) : ( ) : (
<> <>
{!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto antes de entregar (garantia)</p>} {!os.fotos?.length && <p className="text-[10px] text-amber-600 font-bold uppercase flex items-center gap-1"><AlertTriangle size={11} /> Anexe ao menos 1 foto antes de entregar (garantia)</p>}
@@ -314,6 +312,165 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
); );
}; };
// ── Stepper visual das etapas da OS ─────────────────────────────────────────
const FLUXO_OS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
const EtapasStepper: React.FC<{ os: any }> = ({ os }) => {
if (os.status === 'cancelado') {
return <div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase flex items-center gap-2"><X size={14} /> OS cancelada</div>;
}
const idxAtual = FLUXO_OS.indexOf(os.status);
const dataDe = (s: string) => { const ev = os.eventos?.find((e: any) => e.status === s); return ev ? dataBR(ev.created_at) : ''; };
return (
<div className="flex items-start overflow-x-auto pb-1">
{FLUXO_OS.map((s, i) => {
const done = i < idxAtual, atual = i === idxAtual;
return (
<React.Fragment key={s}>
<div className="flex flex-col items-center min-w-[58px]">
{done ? <CheckCircle2 size={20} className="text-teal-600" />
: atual ? <div className="w-5 h-5 rounded-full bg-violet-600 ring-4 ring-violet-100" />
: <Circle size={20} className="text-gray-200" />}
<span className={`text-[8px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-violet-700' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
<span className="text-[8px] text-gray-300 leading-tight">{dataDe(s)}</span>
</div>
{i < FLUXO_OS.length - 1 && <div className={`flex-1 h-0.5 mt-2.5 min-w-[10px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
</React.Fragment>
);
})}
</div>
);
};
// ── Seção: Componentes enviados ao laboratório ──────────────────────────────
const ComponentesSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [nome, setNome] = useState('');
const [qtd, setQtd] = useState('1');
const [busy, setBusy] = useState(false);
const add = async () => {
if (!nome.trim()) { toast.error('INFORME O COMPONENTE.'); return; }
setBusy(true);
try { await HybridBackend.addProteseComponente(os.id, { nome: nome.trim(), quantidade: parseInt(qtd, 10) || 1 }); setNome(''); setQtd('1'); onChanged(); }
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()); } };
return (
<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>
<div className="space-y-1.5">
{(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">
<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="text-[10px] text-gray-400">{c.enviado_nome} · {dataBR(c.created_at)}</span>
{podeEditar && <button onClick={() => del(c.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div>
))}
{(os.componentes || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum componente registrado.</p>}
</div>
{podeEditar && (
<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" />
<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" />
<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>
);
};
// ── Seção: Fotos (anexar com legenda, autor, remover) ───────────────────────
const FotosSecao: React.FC<{ os: any; podeEditar: boolean; onChanged: () => void }> = ({ os, podeEditar, onChanged }) => {
const toast = useToast();
const [legenda, setLegenda] = useState('');
const [busy, setBusy] = useState(false);
const anexar = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return;
setBusy(true);
try { await HybridBackend.uploadProteseFoto(os.id, file, legenda.trim() || undefined); setLegenda(''); onChanged(); }
catch (err: any) { toast.error((err.message || 'ERRO').toUpperCase()); } finally { setBusy(false); e.target.value = ''; }
};
const del = async (id: string) => { try { await HybridBackend.deleteProteseFoto(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Camera size={12} /> Fotos do trabalho</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{(os.fotos || []).map((f: any) => (
<div key={f.id} className="rounded-lg border border-gray-100 overflow-hidden group relative">
<a href={f.url} target="_blank" rel="noreferrer"><img src={f.url} alt="" className="w-full h-24 object-cover" /></a>
{podeEditar && <button onClick={() => del(f.id)} className="absolute top-1 right-1 bg-white/90 rounded-md p-1 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={12} /></button>}
<div className="px-2 py-1">
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate">{f.legenda}</p>}
<p className="text-[9px] text-gray-400 truncate">{f.uploaded_nome || '—'} · {dataBR(f.created_at)}</p>
</div>
</div>
))}
{(os.fotos || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase col-span-full">Nenhuma foto anexada.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<input value={legenda} onChange={e => setLegenda(e.target.value)} placeholder="Legenda da próxima foto (opcional)" className="flex-1 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
<label className={`flex items-center gap-1.5 px-3 rounded-lg border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
<ImagePlus size={14} /> Anexar
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
</label>
</div>
)}
</div>
);
};
// ── Seção: Pagamentos (dois lados) ──────────────────────────────────────────
const PagamentosSecao: React.FC<{ os: any; modo: 'bancada' | 'clinica'; podeEditar: boolean; onChanged: () => void }> = ({ os, modo, podeEditar, onChanged }) => {
const toast = useToast();
const [lado, setLado] = useState<'lab' | 'clinica'>(modo === 'bancada' ? 'lab' : 'clinica');
const [valor, setValor] = useState('');
const [forma, setForma] = useState('');
const [busy, setBusy] = useState(false);
const pags = os.pagamentos || [];
const totalLab = pags.filter((p: any) => p.lado === 'lab').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
const totalClinica = pags.filter((p: any) => p.lado === 'clinica').reduce((s: number, p: any) => s + Number(p.valor || 0), 0);
const add = async () => {
const v = parseFloat(valor);
if (!(v > 0)) { toast.error('INFORME UM VALOR.'); return; }
setBusy(true);
try { await HybridBackend.addProtesePagamento(os.id, { lado, valor: v, forma: forma.trim() || undefined }); setValor(''); setForma(''); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
const del = async (id: string) => { try { await HybridBackend.deleteProtesePagamento(id); onChanged(); } catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } };
return (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase mb-2 flex items-center gap-1.5"><Wallet size={12} /> Valores recebidos</p>
<div className="grid grid-cols-2 gap-2 mb-2">
<div className="bg-green-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-green-600 uppercase">Recebido do paciente</p><p className="text-sm font-black text-green-700">{fmtBRL(totalClinica)}</p></div>
<div className="bg-violet-50 rounded-xl px-3 py-2"><p className="text-[9px] font-black text-violet-600 uppercase">Pago ao laboratório</p><p className="text-sm font-black text-violet-700">{fmtBRL(totalLab)}</p></div>
</div>
<div className="space-y-1.5">
{pags.map((p: any) => (
<div key={p.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase ${p.lado === 'lab' ? 'bg-violet-100 text-violet-700' : 'bg-green-100 text-green-700'}`}>{p.lado === 'lab' ? 'Lab' : 'Paciente'}</span>
<span className="font-black text-gray-700">{fmtBRL(p.valor)}</span>
<span className="text-[10px] text-gray-400 flex-1 truncate">{p.forma ? `${p.forma} · ` : ''}{p.recebido_nome} · {dataBR(p.data)}</span>
{podeEditar && <button onClick={() => del(p.id)} className="text-gray-300 hover:text-red-500"><Trash2 size={13} /></button>}
</div>
))}
{pags.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum pagamento registrado.</p>}
</div>
{podeEditar && (
<div className="flex gap-2 mt-2">
<select value={lado} onChange={e => setLado(e.target.value as any)} className="p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400">
<option value="clinica">Paciente</option>
<option value="lab">Lab</option>
</select>
<input value={valor} onChange={e => setValor(e.target.value)} type="number" step="0.01" placeholder="0,00" className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
<input value={forma} onChange={e => setForma(e.target.value)} placeholder="Forma (pix...)" 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>
</div>
)}
</div>
);
};
// ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ─────── // ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ───────
const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const toast = useToast(); const toast = useToast();