Files
scoreodonto.com/frontend/views/TutorPainelView.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

383 lines
17 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
GraduationCap, MessageSquare, Clock, CheckCircle2, XCircle,
ChevronRight, ArrowLeft, Send, Loader2, User, Camera, Activity,
FileText, Heart, Stethoscope, ShieldAlert, DollarSign, Banknote
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
// ─── Tipos ────────────────────────────────────────────────────────
interface Mensagem {
id: string;
autor_tipo: 'CLINICA' | 'TUTOR';
autor_nome?: string;
conteudo: string;
created_at: string;
}
interface Solicitacao {
id: string;
tratamento_id: string;
paciente_nome?: string;
tutor_nome?: string;
tipo: string;
descricao?: string;
status: string;
created_at: string;
total_mensagens?: number;
mensagens?: Mensagem[];
fotos?: { id: string; url: string; slot: string }[];
// Financeiro (vem de ortho_tutoring_request)
valor_consultoria?: number | string;
comissao_pct?: number | string;
valor_tutor?: number | string;
status_repasse?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────
const TIPO_LABELS: Record<string, string> = {
SEGUNDA_OPINIAO: 'SEGUNDA OPINIÃO',
PLANEJAMENTO_COMPLETO: 'PLANEJAMENTO COMPLETO',
DISCUSSAO_CASO: 'DISCUSSÃO DE CASO',
ALINHADORES: 'ALINHADORES',
ORTO_CIRURGICO: 'ORTO-CIRÚRGICO',
ENCAMINHAMENTO: 'ENCAMINHAMENTO',
};
const STATUS_STYLE: Record<string, { cls: string; label: string; icon: React.ReactNode }> = {
AGUARDANDO: { cls: 'bg-amber-100 text-amber-700', label: 'AGUARDANDO', icon: <Clock size={12} /> },
EM_ANALISE: { cls: 'bg-blue-100 text-blue-700', label: 'EM ANÁLISE', icon: <Activity size={12} /> },
RESPONDIDO: { cls: 'bg-green-100 text-green-700', label: 'RESPONDIDO', icon: <CheckCircle2 size={12} /> },
FECHADO: { cls: 'bg-gray-100 text-gray-500', label: 'FECHADO', icon: <XCircle size={12} /> },
};
const STATUS_ACTIONS: Record<string, { next: string; label: string; cls: string }[]> = {
AGUARDANDO: [{ next: 'EM_ANALISE', label: 'ACEITAR CASO', cls: 'bg-teal-600 hover:bg-teal-700 text-white' }],
EM_ANALISE: [
{ next: 'RESPONDIDO', label: 'MARCAR COMO RESPONDIDO', cls: 'bg-green-600 hover:bg-green-700 text-white' },
{ next: 'FECHADO', label: 'FECHAR CASO', cls: 'bg-gray-600 hover:bg-gray-700 text-white' },
],
RESPONDIDO: [{ next: 'FECHADO', label: 'FECHAR CASO', cls: 'bg-gray-600 hover:bg-gray-700 text-white' }],
FECHADO: [],
};
// ─── Chat do tutor ────────────────────────────────────────────────
const TutorResponseChat: React.FC<{
sol: Solicitacao;
tutorNome: string;
onUpdated: (updated: Solicitacao) => void;
}> = ({ sol, tutorNome, onUpdated }) => {
const toast = useToast();
const [texto, setTexto] = useState('');
const [enviando, setEnviando] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const msgs = sol.mensagens || [];
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [msgs]);
const send = async () => {
const txt = texto.trim();
if (!txt) return;
setEnviando(true);
try {
const res = await HybridBackend.sendMensagemTutoria(sol.id, txt, 'TUTOR', tutorNome);
if (!res.success) { toast.error('ERRO AO ENVIAR.'); return; }
onUpdated({ ...sol, mensagens: [...msgs, res.mensagem] });
setTexto('');
} catch { toast.error('ERRO AO ENVIAR.'); }
finally { setEnviando(false); }
};
const changeStatus = async (newStatus: string) => {
const res = await HybridBackend.updateStatusTutoria(sol.id, newStatus);
if (!res.success) { toast.error('ERRO AO ATUALIZAR STATUS.'); return; }
toast.success('STATUS ATUALIZADO!');
onUpdated({ ...sol, status: newStatus });
};
return (
<div className="flex flex-col gap-4">
{/* Banner parecer consultivo */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 flex items-start gap-2">
<ShieldAlert size={15} className="text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-black text-amber-700 uppercase">PARECER TÉCNICO CONSULTIVO</p>
<p className="text-[10px] text-amber-600 font-medium leading-relaxed mt-0.5">
O seu parecer tem caráter estritamente consultivo e não vinculante. A decisão clínica e a execução do tratamento são de responsabilidade exclusiva do profissional solicitante.
</p>
</div>
</div>
{/* Info financeira */}
{sol.valor_consultoria && (
<div className="bg-green-50 border border-green-200 rounded-xl p-3 grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-[9px] font-black text-gray-400 uppercase">VALOR TOTAL</p>
<p className="font-black text-green-700 text-sm">R$ {Number(sol.valor_consultoria).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</p>
</div>
<div>
<p className="text-[9px] font-black text-gray-400 uppercase">SEU REPASSE ({sol.comissao_pct ? `${100 - Number(sol.comissao_pct)}%` : '—'})</p>
<p className="font-black text-emerald-700 text-sm">{sol.valor_tutor ? `R$ ${Number(sol.valor_tutor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}` : '—'}</p>
</div>
<div>
<p className="text-[9px] font-black text-gray-400 uppercase">STATUS REPASSE</p>
<p className={`font-black text-xs ${sol.status_repasse === 'PAGO' ? 'text-green-600' : 'text-amber-600'}`}>
{sol.status_repasse || 'PENDENTE'}
</p>
</div>
</div>
)}
{/* Chat */}
<div className="bg-gray-50 rounded-xl border border-gray-200 p-4 space-y-3">
<p className="text-[10px] font-black text-gray-400 uppercase">CONVERSA</p>
<div className="max-h-64 overflow-y-auto space-y-2 pr-1">
{msgs.length === 0 ? (
<p className="text-[10px] text-gray-400 font-bold uppercase text-center py-4">NENHUMA MENSAGEM</p>
) : msgs.map(m => (
<div key={m.id} className={`flex ${m.autor_tipo === 'TUTOR' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-3 py-2 rounded-xl text-xs font-medium ${
m.autor_tipo === 'TUTOR'
? 'bg-emerald-600 text-white rounded-br-none'
: 'bg-white border border-gray-200 text-gray-800 rounded-bl-none'
}`}>
{m.autor_nome && (
<p className={`text-[9px] font-black uppercase mb-0.5 ${m.autor_tipo === 'TUTOR' ? 'text-emerald-200' : 'text-gray-400'}`}>
{m.autor_nome}
</p>
)}
<p>{m.conteudo}</p>
<p className={`text-[9px] mt-1 ${m.autor_tipo === 'TUTOR' ? 'text-emerald-200' : 'text-gray-400'}`}>
{new Date(m.created_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{sol.status !== 'FECHADO' && (
<div className="flex gap-2">
<input
type="text"
value={texto}
onChange={e => setTexto(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder="SUA RESPOSTA PARA A CLÍNICA..."
className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-xs font-medium focus:outline-none focus:border-emerald-400 bg-white"
/>
<button onClick={send} disabled={enviando || !texto.trim()}
className="px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl disabled:opacity-40 transition-colors">
{enviando ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</div>
)}
</div>
{/* Ações de status */}
{STATUS_ACTIONS[sol.status]?.length > 0 && (
<div className="flex gap-2 flex-wrap">
{STATUS_ACTIONS[sol.status].map(a => (
<button key={a.next} onClick={() => changeStatus(a.next)}
className={`px-4 py-2 rounded-xl text-[11px] font-black uppercase transition-colors ${a.cls}`}>
{a.label}
</button>
))}
</div>
)}
</div>
);
};
// ─── Detalhe da solicitação ───────────────────────────────────────
const SolicitacaoDetail: React.FC<{
solId: string;
tutorNome: string;
onBack: () => void;
}> = ({ solId, tutorNome, onBack }) => {
const [sol, setSol] = useState<Solicitacao | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
const res = await HybridBackend.getSolicitacaoTutoria(solId);
if (res.success) setSol(res.solicitacao);
setLoading(false);
}, [solId]);
useEffect(() => { load(); }, [load]);
if (loading) return (
<div className="flex items-center justify-center h-64"><Loader2 size={28} className="animate-spin text-gray-300" /></div>
);
if (!sol) return <p className="text-center text-gray-400 font-bold uppercase text-sm py-8">CASO NÃO ENCONTRADO.</p>;
const st = STATUS_STYLE[sol.status] || STATUS_STYLE.AGUARDANDO;
return (
<div className="space-y-5">
{/* Topbar */}
<div className="flex items-center gap-3">
<button onClick={onBack} className="flex items-center gap-1.5 text-gray-500 hover:text-gray-800 text-xs font-black uppercase transition-colors">
<ArrowLeft size={16} /> VOLTAR
</button>
<span className={`ml-auto flex items-center gap-1.5 text-[10px] font-black px-3 py-1 rounded-full uppercase ${st.cls}`}>
{st.icon} {st.label}
</span>
</div>
{/* Info do caso */}
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center font-black text-purple-600">
{sol.paciente_nome?.charAt(0) || 'P'}
</div>
<div>
<p className="font-black text-gray-800 uppercase">{sol.paciente_nome || 'PACIENTE'}</p>
<p className="text-[10px] text-gray-500 font-bold uppercase">{TIPO_LABELS[sol.tipo] || sol.tipo}</p>
</div>
<p className="ml-auto text-[10px] text-gray-400 font-bold">{new Date(sol.created_at).toLocaleDateString()}</p>
</div>
{sol.descricao && (
<div className="bg-gray-50 border border-gray-100 rounded-lg p-3">
<p className="text-[10px] font-black text-gray-400 uppercase mb-1">DESCRIÇÃO DA CLÍNICA</p>
<p className="text-xs text-gray-700 font-medium">{sol.descricao}</p>
</div>
)}
</div>
{/* Fotos do pacote */}
{sol.fotos && sol.fotos.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-5">
<p className="text-[10px] font-black text-gray-400 uppercase mb-3 flex items-center gap-2">
<Camera size={13} /> FOTOS DO PACIENTE ({sol.fotos.length})
</p>
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-2">
{sol.fotos.map(f => (
<div key={f.id} className="aspect-square rounded-lg overflow-hidden border border-gray-200">
<img src={f.url} alt={f.slot} className="w-full h-full object-cover" />
</div>
))}
</div>
</div>
)}
{/* Chat + ações */}
<TutorResponseChat
sol={sol}
tutorNome={tutorNome}
onUpdated={setSol}
/>
</div>
);
};
// ─── View principal do painel do tutor ───────────────────────────
export const TutorPainelView: React.FC = () => {
const [solicitacoes, setSolicitacoes] = useState<Solicitacao[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<string | null>(null);
const tutorNome = HybridBackend.getCurrentUserName() || 'TUTOR';
const load = useCallback(async () => {
setLoading(true);
try {
// Por enquanto busca todas — quando tutor tiver ID ligado ao usuário, filtrar
const res = await HybridBackend.getSolicitacoesTutoria();
if (res.success) setSolicitacoes(res.solicitacoes || []);
} catch {}
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
if (selected) {
return (
<div className="space-y-6">
<PageHeader title="TUTORIA ORTODÔNTICA" />
<SolicitacaoDetail
solId={selected}
tutorNome={tutorNome}
onBack={() => { setSelected(null); load(); }}
/>
</div>
);
}
const groups: Record<string, Solicitacao[]> = {
AGUARDANDO: [],
EM_ANALISE: [],
RESPONDIDO: [],
FECHADO: [],
};
for (const s of solicitacoes) {
if (groups[s.status]) groups[s.status].push(s);
}
return (
<div className="space-y-6">
<PageHeader title="PAINEL DO TUTOR">
<span className="text-[11px] font-black text-gray-500 uppercase">{solicitacoes.length} CASO(S)</span>
</PageHeader>
{loading ? (
<div className="flex items-center justify-center h-40"><Loader2 size={28} className="animate-spin text-gray-300" /></div>
) : solicitacoes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 gap-3">
<GraduationCap size={36} />
<p className="text-sm font-black uppercase">NENHUM CASO RECEBIDO</p>
<p className="text-xs text-gray-400">AS SOLICITAÇÕES DAS CLÍNICAS APARECEM AQUI.</p>
</div>
) : (
<div className="space-y-6">
{(['AGUARDANDO','EM_ANALISE','RESPONDIDO','FECHADO'] as const).map(status => {
const items = groups[status];
if (!items.length) return null;
const st = STATUS_STYLE[status];
return (
<div key={status}>
<div className="flex items-center gap-2 mb-3">
<span className={`flex items-center gap-1.5 text-[10px] font-black px-3 py-1 rounded-full uppercase ${st.cls}`}>
{st.icon} {st.label}
</span>
<span className="text-[10px] font-black text-gray-400">{items.length} CASO(S)</span>
</div>
<div className="space-y-2">
{items.map(s => (
<button key={s.id} onClick={() => setSelected(s.id)}
className="w-full flex items-center gap-4 p-4 bg-white border border-gray-200 rounded-xl hover:border-emerald-300 hover:shadow-sm transition-all text-left group">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center font-black text-purple-600 flex-shrink-0">
{s.paciente_nome?.charAt(0) || 'P'}
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-sm text-gray-800 truncate uppercase">{s.paciente_nome || 'PACIENTE'}</p>
<p className="text-[10px] text-gray-500 font-bold uppercase">{TIPO_LABELS[s.tipo] || s.tipo}</p>
{s.descricao && <p className="text-[10px] text-gray-400 mt-0.5 truncate">{s.descricao}</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{Number(s.total_mensagens) > 0 && (
<span className="flex items-center gap-1 text-[10px] font-black text-gray-400">
<MessageSquare size={12} /> {s.total_mensagens}
</span>
)}
<p className="text-[10px] text-gray-400 font-bold">{new Date(s.created_at).toLocaleDateString()}</p>
<ChevronRight size={16} className="text-gray-300 group-hover:text-emerald-400 transition-colors" />
</div>
</button>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
};