ba062e92d0
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed - OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
383 lines
17 KiB
TypeScript
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-blue-600 hover:bg-blue-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-indigo-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-indigo-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-indigo-200' : 'text-gray-400'}`}>
|
|
{m.autor_nome}
|
|
</p>
|
|
)}
|
|
<p>{m.conteudo}</p>
|
|
<p className={`text-[9px] mt-1 ${m.autor_tipo === 'TUTOR' ? 'text-indigo-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-indigo-400 bg-white"
|
|
/>
|
|
<button onClick={send} disabled={enviando || !texto.trim()}
|
|
className="px-3 py-2 bg-indigo-600 hover:bg-indigo-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-indigo-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-indigo-400 transition-colors" />
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|