From 623c9990d6ad7c93ef79dae90b41c43695bdbb6e Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 23 Jun 2026 23:41:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20profissionaliza=20a=20OS=20?= =?UTF-8?q?=E2=80=94=20etapas,=20fotos,=20componentes,=20pagamentos=20e=20?= =?UTF-8?q?auditoria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 148 ++++++++++++++++++++- frontend/services/backend.ts | 30 ++++- frontend/views/ProteseView.tsx | 227 ++++++++++++++++++++++++++++----- 3 files changed, 366 insertions(+), 39 deletions(-) diff --git a/backend/server.js b/backend/server.js index 8b4d308..0b9a125 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 5a55d40..79a141d 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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' }); diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 986c04d..446f346 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -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 = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', @@ -222,54 +227,53 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose return (
-
e.stopPropagation()}> -
+
e.stopPropagation()}> +

{os.tipo}

{os.paciente_nome || 'Sem paciente'} {os.dentes ? `· dentes ${os.dentes}` : ''}

-
+
{os.tipo_os === 'garantia' && Garantia} {os.atrasada && Atrasada} - {os.prazo_entrega && {dataBR(os.prazo_entrega)}} + {os.prazo_entrega && Prazo {dataBR(os.prazo_entrega)}}
+ + {/* Stepper de etapas */} + + {os.garantia_ate && (
Garantia até {dataBR(os.garantia_ate)} ({os.garantia_meses || 12} meses)
)} -
+
{os.material &&
Material{os.material}
} {os.cor &&
Cor{os.cor}
} {os.dentista_nome &&
Dentista{os.dentista_nome}
} {modo === 'bancada' && Number(os.custo) > 0 &&
Custo lab{fmtBRL(os.custo)}
} - {modo === 'clinica' && Number(os.valor) > 0 &&
Valor{fmtBRL(os.valor)}
} + {modo === 'clinica' && Number(os.valor) > 0 &&
Valor cobrado{fmtBRL(os.valor)}
}
{os.observacoes &&

{os.observacoes}

} - {/* Fotos */} - {os.fotos?.length > 0 && ( -
- {os.fotos.map((f: any) => ( - - ))} -
- )} + + + - {/* Timeline */} + {/* Histórico (auditoria) */}
-

Histórico

-
+

Histórico

+
{os.eventos?.map((ev: any, i: number) => ( -
- -
- {STATUS_LABEL[ev.status] || ev.observacao || ev.status} - · {dataBR(ev.created_at)} {ev.actor_nome ? `· ${ev.actor_nome}` : ''} +
+ +
+ {ev.observacao || STATUS_LABEL[ev.status] || ev.status} + {dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}
))} @@ -281,18 +285,12 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose {!finalizado && (
{modo === 'bancada' ? ( - <> -
- {LAB_FLUXO.map(s => ( - - ))} -
- - +
+ {LAB_FLUXO.map(s => ( + + ))} +
) : ( <> {!os.fotos?.length &&

Anexe ao menos 1 foto antes de entregar (garantia)

} @@ -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
OS cancelada
; + } + 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 ( +
+ {FLUXO_OS.map((s, i) => { + const done = i < idxAtual, atual = i === idxAtual; + return ( + +
+ {done ? + : atual ?
+ : } + {STATUS_LABEL[s]} + {dataDe(s)} +
+ {i < FLUXO_OS.length - 1 &&
} + + ); + })} +
+ ); +}; + +// ── 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 ( +
+

Componentes enviados

+
+ {(os.componentes || []).map((c: any) => ( +
+ {c.quantidade}× + {c.nome} + {c.enviado_nome} · {dataBR(c.created_at)} + {podeEditar && } +
+ ))} + {(os.componentes || []).length === 0 &&

Nenhum componente registrado.

} +
+ {podeEditar && ( +
+ 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" /> + 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" /> + +
+ )} +
+ ); +}; + +// ── 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) => { + 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 ( +
+

Fotos do trabalho

+
+ {(os.fotos || []).map((f: any) => ( +
+ + {podeEditar && } +
+ {f.legenda &&

{f.legenda}

} +

{f.uploaded_nome || '—'} · {dataBR(f.created_at)}

+
+
+ ))} + {(os.fotos || []).length === 0 &&

Nenhuma foto anexada.

} +
+ {podeEditar && ( +
+ 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" /> + +
+ )} +
+ ); +}; + +// ── 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 ( +
+

Valores recebidos

+
+

Recebido do paciente

{fmtBRL(totalClinica)}

+

Pago ao laboratório

{fmtBRL(totalLab)}

+
+
+ {pags.map((p: any) => ( +
+ {p.lado === 'lab' ? 'Lab' : 'Paciente'} + {fmtBRL(p.valor)} + {p.forma ? `${p.forma} · ` : ''}{p.recebido_nome} · {dataBR(p.data)} + {podeEditar && } +
+ ))} + {pags.length === 0 &&

Nenhum pagamento registrado.

} +
+ {podeEditar && ( +
+ + 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" /> + 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" /> + +
+ )} +
+ ); +}; + // ── Modal: Catálogo do protético (produtos com preço fixo + garantia) ─────── const ProdutosModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { const toast = useToast();