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:
+145
-3
@@ -3973,15 +3973,115 @@ 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 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({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
eventos,
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// 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).
|
||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
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.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||||
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)',
|
||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]);
|
||||
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]);
|
||||
// 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)}` });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -5732,6 +5839,41 @@ async function runMigrations() {
|
||||
FROM clinicas c
|
||||
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)`,
|
||||
// ── 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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
|
||||
@@ -778,13 +778,41 @@ 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) => {
|
||||
uploadProteseFoto: async (id: string, file: File, legenda?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
if (legenda) fd.append('legenda', legenda);
|
||||
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();
|
||||
},
|
||||
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) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
|
||||
+192
-35
@@ -1,5 +1,5 @@
|
||||
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 { useToast } from '../contexts/ToastContext.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 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> = {
|
||||
solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção',
|
||||
@@ -222,54 +227,53 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
|
||||
return (
|
||||
<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="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<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 z-10">
|
||||
<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>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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.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>
|
||||
|
||||
{/* Stepper de etapas */}
|
||||
<EtapasStepper os={os} />
|
||||
|
||||
{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">
|
||||
<ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
|
||||
</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.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>}
|
||||
{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>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3">{os.observacoes}</p>}
|
||||
|
||||
{/* Fotos */}
|
||||
{os.fotos?.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{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>
|
||||
)}
|
||||
<ComponentesSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<FotosSecao os={os} podeEditar={!finalizado} onChanged={carregar} />
|
||||
<PagamentosSecao os={os} modo={modo} podeEditar={!finalizado} onChanged={carregar} />
|
||||
|
||||
{/* Timeline */}
|
||||
{/* Histórico (auditoria) */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
<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.5">
|
||||
{os.eventos?.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-400 mt-1 shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-gray-700">{STATUS_LABEL[ev.status] || ev.observacao || ev.status}</span>
|
||||
<span className="text-gray-400"> · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''}</span>
|
||||
<div key={i} className="flex items-start gap-2.5 text-xs">
|
||||
<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 className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
|
||||
<span className="text-gray-400 block text-[11px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -281,18 +285,12 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
{!finalizado && (
|
||||
<div className="p-5 border-t border-gray-100 sticky bottom-0 bg-white space-y-2">
|
||||
{modo === 'bancada' ? (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LAB_FLUXO.map(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>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{LAB_FLUXO.map(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>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!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) ───────
|
||||
const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
|
||||
Reference in New Issue
Block a user