Files
scoreodonto.com/frontend/components/AvaliacaoEditor.tsx
T
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
Financeiro V1 (Épicos 1–4):
- FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado"
- prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional)
- normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI)
- baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão
- relatório financeiro (período, paciente, profissional, forma, convênio, glosa)
- orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato

Financeiro V2 (Comissão/Repasse):
- regras de % (padrão/procedimento/override por profissional) + custo de laboratório
- base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno

Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório

GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos)

Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio

Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS;
sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de
locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala)

Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:07 +02:00

230 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { X, Camera, Trash2, Check, Loader2, Save, ClipboardList, Image as ImageIcon } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// ── Odontograma FDI (dentição permanente) ───────────────────────────────────
const UP_RIGHT = ['18', '17', '16', '15', '14', '13', '12', '11'];
const UP_LEFT = ['21', '22', '23', '24', '25', '26', '27', '28'];
const LO_RIGHT = ['48', '47', '46', '45', '44', '43', '42', '41'];
const LO_LEFT = ['31', '32', '33', '34', '35', '36', '37', '38'];
type StatusKey = 'higido' | 'carie' | 'restaurado' | 'ausente' | 'coroa' | 'tratamento' | 'implante' | 'fratura';
const STATUS: Record<StatusKey, { label: string; cor: string; bg: string }> = {
higido: { label: 'HÍGIDO', cor: '#94a3b8', bg: '#f8fafc' },
carie: { label: 'CÁRIE', cor: '#dc2626', bg: '#fef2f2' },
restaurado: { label: 'RESTAURADO', cor: '#2563eb', bg: '#eff6ff' },
ausente: { label: 'AUSENTE', cor: '#1f2937', bg: '#f3f4f6' },
coroa: { label: 'COROA', cor: '#d97706', bg: '#fffbeb' },
tratamento: { label: 'TRAT. CANAL',cor: '#7c3aed', bg: '#f5f3ff' },
implante: { label: 'IMPLANTE', cor: '#0d9488', bg: '#f0fdfa' },
fratura: { label: 'FRATURA', cor: '#0d9488', bg: '#fff7ed' },
};
const STATUS_ORDER: StatusKey[] = ['higido', 'carie', 'restaurado', 'tratamento', 'coroa', 'implante', 'fratura', 'ausente'];
interface ToothState { status?: StatusKey; nota?: string; }
type Odontograma = Record<string, ToothState>;
const Tooth: React.FC<{ num: string; st?: ToothState; selected: boolean; onClick: () => void }> = ({ num, st, selected, onClick }) => {
const cfg = st?.status ? STATUS[st.status] : null;
return (
<button
onClick={onClick}
title={cfg ? `${num} · ${cfg.label}` : num}
className={`relative flex flex-col items-center justify-center w-7 h-9 sm:w-8 sm:h-10 rounded-md border text-[9px] font-black transition-all ${selected ? 'ring-2 ring-teal-500 ring-offset-1 scale-110 z-10' : 'hover:scale-105'}`}
style={{ borderColor: cfg ? cfg.cor : '#e2e8f0', backgroundColor: cfg ? cfg.bg : '#fff', color: cfg ? cfg.cor : '#64748b' }}
>
{st?.status === 'ausente' && <span className="absolute inset-0 flex items-center justify-center text-base leading-none" style={{ color: '#1f2937' }}>×</span>}
<span className="relative z-[1]">{num}</span>
{st?.nota ? <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-amber-400 border border-white" /> : null}
</button>
);
};
interface Props {
agendamentoId?: string;
avaliacaoId?: string;
pacienteNome?: string;
onClose: () => void;
onSaved?: () => void;
}
export const AvaliacaoEditor: React.FC<Props> = ({ agendamentoId, avaliacaoId, pacienteNome, onClose, onSaved }) => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [aval, setAval] = useState<any>(null);
const [odonto, setOdonto] = useState<Odontograma>({});
const [obs, setObs] = useState('');
const [sel, setSel] = useState<string | null>(null);
const [fotos, setFotos] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
(async () => {
setLoading(true);
let a: any = null;
if (avaliacaoId) a = await HybridBackend.getAvaliacao(avaliacaoId);
else if (agendamentoId) a = await HybridBackend.getAvaliacaoDoAgendamento(agendamentoId);
setAval(a);
setOdonto((a?.odontograma && typeof a.odontograma === 'object') ? a.odontograma : {});
setObs(a?.observacoes || '');
setFotos(a?.fotos || []);
setLoading(false);
})();
}, [agendamentoId, avaliacaoId]);
const setToothStatus = (status: StatusKey) => {
if (!sel) return;
setOdonto(prev => ({ ...prev, [sel]: { ...prev[sel], status: prev[sel]?.status === status ? undefined : status } }));
};
const setToothNota = (nota: string) => {
if (!sel) return;
setOdonto(prev => ({ ...prev, [sel]: { ...prev[sel], nota } }));
};
const persist = async (status?: string) => {
if (!aval?.id) { toast.error('AVALIAÇÃO NÃO ENCONTRADA.'); return; }
setSaving(true);
const r = await HybridBackend.salvarAvaliacao(aval.id, { odontograma: odonto, observacoes: obs, ...(status ? { status } : {}) });
setSaving(false);
if (r?.success) {
toast.success(status === 'concluida' ? 'AVALIAÇÃO CONCLUÍDA.' : 'AVALIAÇÃO SALVA.');
onSaved?.();
if (status === 'concluida') onClose();
} else { toast.error('ERRO AO SALVAR AVALIAÇÃO.'); }
};
const onPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !aval?.id) return;
setUploading(true);
const r = await HybridBackend.uploadAvaliacaoFoto(aval.id, file, sel || undefined);
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
if (r?.success && r.foto) { setFotos(prev => [...prev, r.foto]); toast.success('FOTO ENVIADA.'); }
else toast.error('ERRO AO ENVIAR FOTO.');
};
const delFoto = async (id: string) => {
const r = await HybridBackend.deleteAvaliacaoFoto(id);
if (r?.success) setFotos(prev => prev.filter(f => f.id !== id));
};
const selSt = sel ? odonto[sel] : undefined;
const totalMarcados = Object.values(odonto).filter(t => t?.status).length;
return (
<div className="fixed inset-0 z-[120] bg-black/40 flex items-stretch sm:items-center justify-center sm:p-4" onClick={onClose}>
<div className="bg-white w-full sm:max-w-3xl sm:rounded-2xl shadow-2xl flex flex-col max-h-screen sm:max-h-[92vh] overflow-hidden" onClick={e => e.stopPropagation()}>
{/* header */}
<div className="flex items-center gap-3 px-4 sm:px-6 py-4 border-b border-gray-100">
<div className="w-9 h-9 rounded-xl bg-teal-50 flex items-center justify-center text-teal-600 flex-shrink-0"><ClipboardList size={18} /></div>
<div className="min-w-0 flex-1">
<p className="text-sm font-black uppercase text-gray-800 truncate leading-tight">Avaliação clínica</p>
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{pacienteNome || aval?.paciente_nome || '—'}</p>
</div>
<button onClick={onClose} className="p-2 text-gray-400 hover:text-gray-700 rounded-lg hover:bg-gray-50"><X size={20} /></button>
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center py-20 text-gray-400"><Loader2 size={28} className="animate-spin" /></div>
) : !aval ? (
<div className="flex-1 flex flex-col items-center justify-center py-20 px-6 text-center">
<ClipboardList size={40} className="text-gray-300 mb-3" />
<p className="text-sm font-bold text-gray-500 uppercase">Nenhuma avaliação vinculada a este agendamento.</p>
</div>
) : (
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 sm:px-6 py-4 space-y-5">
{/* Odontograma */}
<div>
<p className="text-[11px] font-black uppercase text-gray-500 mb-2">Odontograma · {totalMarcados} marcados</p>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<div className="flex justify-center gap-1.5">
<div className="flex gap-1 pr-2 border-r-2 border-gray-200">{UP_RIGHT.map(n => <Tooth key={n} num={n} st={odonto[n]} selected={sel === n} onClick={() => setSel(n)} />)}</div>
<div className="flex gap-1 pl-2">{UP_LEFT.map(n => <Tooth key={n} num={n} st={odonto[n]} selected={sel === n} onClick={() => setSel(n)} />)}</div>
</div>
<div className="flex justify-center gap-1.5">
<div className="flex gap-1 pr-2 border-r-2 border-gray-200">{LO_RIGHT.map(n => <Tooth key={n} num={n} st={odonto[n]} selected={sel === n} onClick={() => setSel(n)} />)}</div>
<div className="flex gap-1 pl-2">{LO_LEFT.map(n => <Tooth key={n} num={n} st={odonto[n]} selected={sel === n} onClick={() => setSel(n)} />)}</div>
</div>
</div>
</div>
{/* Painel do dente selecionado */}
{sel ? (
<div className="border border-teal-100 bg-teal-50/40 rounded-xl p-3 space-y-3">
<p className="text-[11px] font-black uppercase text-teal-700">Dente {sel}</p>
<div className="flex flex-wrap gap-1.5">
{STATUS_ORDER.map(k => {
const active = selSt?.status === k;
return (
<button key={k} onClick={() => setToothStatus(k)}
className="px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase border transition-all"
style={{ borderColor: STATUS[k].cor, color: active ? '#fff' : STATUS[k].cor, backgroundColor: active ? STATUS[k].cor : '#fff' }}>
{STATUS[k].label}
</button>
);
})}
</div>
<input value={selSt?.nota || ''} onChange={e => setToothNota(e.target.value)} placeholder="Observação do dente (opcional)"
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-xs focus:outline-none focus:border-teal-400" />
</div>
) : (
<p className="text-[11px] text-gray-400 font-medium text-center -mt-2">Toque em um dente para marcar o estado.</p>
)}
{/* Observações gerais */}
<div>
<p className="text-[11px] font-black uppercase text-gray-500 mb-2">Observações gerais</p>
<textarea value={obs} onChange={e => setObs(e.target.value)} rows={3} placeholder="Diagnóstico, conduta, plano de tratamento…"
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none focus:outline-none focus:border-teal-400 custom-scrollbar" />
</div>
{/* Fotos dos dentes */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] font-black uppercase text-gray-500">Fotos {sel ? `· dente ${sel}` : ''}</p>
<button onClick={() => fileRef.current?.click()} disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-teal-600 text-white text-[10px] font-black uppercase hover:bg-teal-700 disabled:opacity-50">
{uploading ? <Loader2 size={13} className="animate-spin" /> : <Camera size={13} />} Adicionar
</button>
<input ref={fileRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onPick} />
</div>
{fotos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-gray-300 border-2 border-dashed border-gray-100 rounded-xl">
<ImageIcon size={26} /><span className="text-[10px] font-bold uppercase mt-1">Sem fotos</span>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{fotos.map(f => (
<div key={f.id} className="relative group aspect-square rounded-lg overflow-hidden border border-gray-200">
<img src={f.url} alt={f.dente || ''} className="w-full h-full object-cover" />
{f.dente ? <span className="absolute top-1 left-1 bg-black/60 text-white text-[9px] font-black px-1.5 py-0.5 rounded">{f.dente}</span> : null}
<button onClick={() => delFoto(f.id)} className="absolute top-1 right-1 bg-red-500/90 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={12} /></button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* footer */}
{!loading && aval && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-3 border-t border-gray-100 bg-gray-50/50">
<button onClick={() => persist()} disabled={saving}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border-2 border-gray-200 text-gray-600 text-[11px] font-black uppercase hover:border-gray-300 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
<button onClick={() => persist('concluida')} disabled={saving}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-emerald-600 text-white text-[11px] font-black uppercase hover:bg-emerald-700 disabled:opacity-50">
<Check size={14} /> Concluir
</button>
</div>
)}
</div>
</div>
);
};