3e64514b08
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>
230 lines
13 KiB
TypeScript
230 lines
13 KiB
TypeScript
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>
|
||
);
|
||
};
|