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>
524 lines
30 KiB
TypeScript
524 lines
30 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { StackModal, StackPage, useStackModal } from './StackModal.tsx';
|
||
import { HybridBackend } from '../services/backend.ts';
|
||
import { useToast } from '../contexts/ToastContext.tsx';
|
||
import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
|
||
import {
|
||
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
|
||
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
|
||
Search, UserPlus, Link2, Trash2, ClipboardList
|
||
} from 'lucide-react';
|
||
|
||
interface Props {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
appointment: any | null;
|
||
dentists: any[] | null;
|
||
onChanged: () => void; // recarrega a agenda
|
||
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
|
||
onAtender?: () => void;
|
||
podeAtender?: boolean;
|
||
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
|
||
}
|
||
|
||
// Cache simples na sessão do modal (evita refetch ao voltar/avançar).
|
||
const _cache: Record<string, { faltas?: number; familia?: { grupo: any; membros: any[] }; score?: { faltas: number; remarcacoes: number; nivel: string } }> = {};
|
||
|
||
const low = (s: any) => String(s || '').toLowerCase();
|
||
const STATUS_LABEL: Record<string, string> = {
|
||
importado: 'A CONFIRMAR · GOOGLE',
|
||
agendado: 'AGENDADO', confirmado: 'CONFIRMADO', reagendado: 'REAGENDADO',
|
||
remarcar: 'A REAGENDAR', cancelado: 'CANCELADO', falta: 'FALTOU', atendido: 'ATENDIDO',
|
||
pendente: 'PENDENTE', concluido: 'CONCLUÍDO', faltou: 'FALTOU',
|
||
};
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
importado: 'bg-violet-50 text-violet-700',
|
||
cancelado: 'bg-red-50 text-red-600', falta: 'bg-red-50 text-red-600', faltou: 'bg-red-50 text-red-600',
|
||
remarcar: 'bg-amber-50 text-amber-600', confirmado: 'bg-emerald-50 text-emerald-600',
|
||
atendido: 'bg-emerald-50 text-emerald-600', concluido: 'bg-emerald-50 text-emerald-600',
|
||
};
|
||
|
||
export const FamiliaChips: React.FC<{ pacienteId: string; onPick?: (m: any) => void }> = ({ pacienteId, onPick }) => {
|
||
const [membros, setMembros] = useState<any[] | null>(_cache[pacienteId]?.familia?.membros ?? null);
|
||
useEffect(() => {
|
||
if (_cache[pacienteId]?.familia) { setMembros(_cache[pacienteId].familia!.membros); return; }
|
||
let alive = true;
|
||
HybridBackend.getFamiliaPaciente(pacienteId).then(f => {
|
||
_cache[pacienteId] = { ..._cache[pacienteId], familia: f };
|
||
if (alive) setMembros(f.membros);
|
||
}).catch(() => alive && setMembros([]));
|
||
return () => { alive = false; };
|
||
}, [pacienteId]);
|
||
if (!membros || membros.length <= 1) return null;
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1"><Users size={11} /> Grupo familiar</label>
|
||
<div className="flex gap-1.5 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5">
|
||
{membros.map(m => {
|
||
const inner = (<>{m.nome}{m.grupofamiliarrelacao ? <span className="text-emerald-400">· {m.grupofamiliarrelacao}</span> : null}</>);
|
||
const cls = 'shrink-0 inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase whitespace-nowrap';
|
||
return onPick
|
||
? <button type="button" key={m.id} onClick={() => onPick(m)} className={cls + ' hover:bg-emerald-100 transition-colors'}>{inner}</button>
|
||
: <span key={m.id} className={cls}>{inner}</span>;
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const FaltasBadge: React.FC<{ pacienteId: string }> = ({ pacienteId }) => {
|
||
const [n, setN] = useState<number | null>(_cache[pacienteId]?.faltas ?? null);
|
||
useEffect(() => {
|
||
if (typeof _cache[pacienteId]?.faltas === 'number') { setN(_cache[pacienteId].faltas!); return; }
|
||
let alive = true;
|
||
HybridBackend.getFaltasPaciente(pacienteId).then(v => {
|
||
_cache[pacienteId] = { ..._cache[pacienteId], faltas: v };
|
||
if (alive) setN(v);
|
||
}).catch(() => alive && setN(0));
|
||
return () => { alive = false; };
|
||
}, [pacienteId]);
|
||
if (!n) return null;
|
||
return (
|
||
<span className="inline-flex items-center gap-1 bg-red-50 text-red-600 rounded-full px-2.5 py-1 text-[10px] font-black uppercase">
|
||
<ShieldAlert size={12} /> {n} falta{n > 1 ? 's' : ''}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// Selo de SCORE do paciente: nível (ATENÇÃO/RISCO) + contadores (faltas · remarcações).
|
||
// Não aparece quando o paciente está limpo (0 faltas e 0 remarcações).
|
||
export const ScoreBadge: React.FC<{ pacienteId: string }> = ({ pacienteId }) => {
|
||
const [sc, setSc] = useState<{ faltas: number; remarcacoes: number; nivel: string } | null>(_cache[pacienteId]?.score ?? null);
|
||
useEffect(() => {
|
||
if (_cache[pacienteId]?.score) { setSc(_cache[pacienteId].score!); return; }
|
||
let alive = true;
|
||
HybridBackend.getScorePaciente(pacienteId).then(s => {
|
||
_cache[pacienteId] = { ..._cache[pacienteId], score: s };
|
||
if (alive) setSc(s);
|
||
}).catch(() => { });
|
||
return () => { alive = false; };
|
||
}, [pacienteId]);
|
||
if (!sc || (sc.faltas === 0 && sc.remarcacoes === 0)) return null;
|
||
const cor = sc.nivel === 'RISCO' ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700';
|
||
const label = sc.nivel === 'RISCO' ? 'RISCO' : 'ATENÇÃO';
|
||
const parts: string[] = [];
|
||
if (sc.faltas) parts.push(`${sc.faltas} falta${sc.faltas > 1 ? 's' : ''}`);
|
||
if (sc.remarcacoes) parts.push(`${sc.remarcacoes} remarc.`);
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-black uppercase ${cor}`}>
|
||
<ShieldAlert size={12} /> {label} · {parts.join(' · ')}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) => {
|
||
const [hist, setHist] = useState<any[] | null>(null);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
HybridBackend.getHistoricoAgendamento(appointmentId).then(h => alive && setHist(h)).catch(() => alive && setHist([]));
|
||
return () => { alive = false; };
|
||
}, [appointmentId]);
|
||
if (hist === null) return <p className="text-xs text-gray-400 font-bold uppercase py-8 text-center">CARREGANDO...</p>;
|
||
if (!hist.length) return <p className="text-xs text-gray-400 font-bold uppercase py-8 text-center">SEM HISTÓRICO.</p>;
|
||
const ACAO: Record<string, string> = { criou: 'criou', reagendou: 'reagendou', editou: 'editou', cancelou: 'cancelou', faltou: 'marcou falta', remarcou: 'pediu remarcação', restaurou: 'restaurou' };
|
||
return (
|
||
<div className="space-y-3">
|
||
{hist.map((h, i) => (
|
||
<div key={i} className="flex gap-3">
|
||
<div className="flex flex-col items-center">
|
||
<div className="w-2 h-2 rounded-full bg-teal-500 mt-1.5" />
|
||
{i < hist.length - 1 && <div className="w-px flex-1 bg-gray-200 my-1" />}
|
||
</div>
|
||
<div className="pb-2 flex-1">
|
||
<p className="text-xs font-bold text-gray-800">
|
||
<span className="text-teal-600">{h.actor_nome || 'Alguém'}</span> {ACAO[h.acao] || h.acao}
|
||
</p>
|
||
<p className="text-[10px] text-gray-400 font-medium">{new Date(h.at).toLocaleString('pt-BR')}</p>
|
||
{h.detalhes?.de && h.detalhes?.para && (
|
||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||
{new Date(h.detalhes.de.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||
{' → '}
|
||
{new Date(h.detalhes.para.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Página de CONFIRMAÇÃO de um importado: busca/desambigua paciente, exige CPF+telefone, vincula.
|
||
const ConfirmImportadoPage: React.FC<{ ag: any; onConfirmed: () => void }> = ({ ag, onConfirmed }) => {
|
||
const { close } = useStackModal();
|
||
const toast = useToast();
|
||
const [q, setQ] = useState('');
|
||
const [results, setResults] = useState<any[]>([]);
|
||
const [sel, setSel] = useState<any | null>(null);
|
||
const [novo, setNovo] = useState(false);
|
||
const [nome, setNome] = useState(ag.pacienteNome || ag.pacientenome || '');
|
||
const [cpf, setCpf] = useState('');
|
||
const [tel, setTel] = useState('');
|
||
const [proc, setProc] = useState(ag.procedimento || '');
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const t = setTimeout(() => {
|
||
HybridBackend.getPacientes(q, 'recentes').then(r => alive && setResults((r.data || []).slice(0, 8))).catch(() => { });
|
||
}, 300);
|
||
return () => { alive = false; clearTimeout(t); };
|
||
}, [q]);
|
||
|
||
const pick = (pac: any) => { setSel(pac); setNovo(false); setCpf(pac.cpf || ''); setTel(pac.telefone || ''); };
|
||
const iniciarNovo = () => { setNovo(true); setSel(null); setCpf(''); setTel(''); };
|
||
|
||
const confirmar = async () => {
|
||
if (!cpf.trim() || !tel.trim()) { toast.error('CPF E TELEFONE SÃO OBRIGATÓRIOS.'); return; }
|
||
setSaving(true);
|
||
try {
|
||
let pacienteId = sel?.id;
|
||
if (novo) {
|
||
if (!nome.trim()) { toast.error('INFORME O NOME.'); setSaving(false); return; }
|
||
pacienteId = `p_${Date.now()}`;
|
||
await HybridBackend.savePaciente({ id: pacienteId, nome, cpf, telefone: tel } as any);
|
||
}
|
||
if (!pacienteId) { toast.error('SELECIONE UM PACIENTE OU CRIE UM NOVO.'); setSaving(false); return; }
|
||
const res = await HybridBackend.confirmarAgendamento(ag.id, { pacienteId, cpf, telefone: tel, procedimento: proc || undefined });
|
||
if (!res.success) { toast.error(res.message || 'ERRO AO CONFIRMAR.'); return; }
|
||
toast.success('AGENDAMENTO CONFIRMADO!');
|
||
onConfirmed(); close();
|
||
} catch { toast.error('ERRO AO CONFIRMAR.'); } finally { setSaving(false); }
|
||
};
|
||
|
||
const faltaDados = !cpf.trim() || !tel.trim();
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-[11px] text-gray-500 leading-snug">Vincule o paciente do cadastro. Se houver homônimos, use CPF/telefone/nascimento para identificar. CPF e telefone são obrigatórios.</p>
|
||
|
||
{!novo && (
|
||
<>
|
||
<div className="relative">
|
||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="Buscar por nome, CPF ou telefone..."
|
||
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-teal-400" />
|
||
</div>
|
||
<div className="max-h-52 overflow-y-auto custom-scrollbar space-y-1.5">
|
||
{results.map(pac => (
|
||
<button key={pac.id} onClick={() => pick(pac)}
|
||
className={`w-full text-left p-2.5 rounded-xl border transition-colors ${sel?.id === pac.id ? 'border-teal-400 bg-teal-50' : 'border-gray-100 hover:bg-gray-50'}`}>
|
||
<p className="text-xs font-black text-gray-800 uppercase truncate">{pac.nome}</p>
|
||
<p className="text-[10px] text-gray-400 font-bold">
|
||
{pac.cpf ? `CPF ${pac.cpf}` : 'sem CPF'} · {pac.telefone || 'sem tel'}{pac.datanascimento ? ` · ${pac.datanascimento}` : ''}
|
||
</p>
|
||
</button>
|
||
))}
|
||
{q && results.length === 0 && <p className="text-[11px] text-gray-400 text-center py-3 uppercase font-bold">Nenhum paciente encontrado.</p>}
|
||
</div>
|
||
<button onClick={iniciarNovo} className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl border-2 border-dashed border-gray-200 text-[11px] font-black uppercase text-gray-500 hover:border-teal-300 hover:text-teal-600 transition-colors">
|
||
<UserPlus size={15} /> Criar novo paciente
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{novo && (
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-black text-gray-500 uppercase">Novo paciente</label>
|
||
<input value={nome} onChange={e => setNome(e.target.value.toUpperCase())} placeholder="NOME COMPLETO"
|
||
className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm font-bold focus:outline-none focus:border-teal-400" />
|
||
<button onClick={() => setNovo(false)} className="text-[10px] font-black text-teal-600 uppercase">← voltar à busca</button>
|
||
</div>
|
||
)}
|
||
|
||
{(sel || novo) && (
|
||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||
{sel && <p className="text-xs font-black text-gray-800 uppercase">{sel.nome}</p>}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="text-[9px] font-black text-gray-400 uppercase">CPF *</label>
|
||
<input value={cpf} onChange={e => setCpf(e.target.value)} placeholder="000.000.000-00"
|
||
className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!cpf.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-teal-400'}`} />
|
||
</div>
|
||
<div>
|
||
<label className="text-[9px] font-black text-gray-400 uppercase">Telefone *</label>
|
||
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="(00) 00000-0000"
|
||
className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!tel.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-teal-400'}`} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-[9px] font-black text-gray-400 uppercase">Procedimento</label>
|
||
<input value={proc} onChange={e => setProc(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO"
|
||
className="w-full px-2.5 py-2 border border-gray-200 rounded-lg text-xs font-bold focus:outline-none focus:border-teal-400" />
|
||
</div>
|
||
{faltaDados && <p className="text-[10px] text-amber-600 font-bold">Preencha CPF e telefone para confirmar.</p>}
|
||
<button onClick={confirmar} disabled={saving || faltaDados}
|
||
className="w-full py-2.5 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all disabled:opacity-50 flex items-center justify-center gap-1.5">
|
||
<Link2 size={15} /> {saving ? 'CONFIRMANDO...' : 'Confirmar e vincular'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Modal SIMPLES do dentista linkado por clínica: sem dados sensíveis, sem agendamento.
|
||
// Só: Atender · Reencaminhar (+motivo) · Solicitar avaliação (+motivo).
|
||
const DentistaRestritoView: React.FC<{ ag: any; nome: string; dentNome: string; dentCor?: string; onAtender?: () => void; onChanged: () => void }> = ({ ag, nome, dentNome, dentCor, onAtender, onChanged }) => {
|
||
const { close } = useStackModal();
|
||
const toast = useToast();
|
||
const [modo, setModo] = useState<'' | 'reencaminhar' | 'avaliacao'>('');
|
||
const [motivo, setMotivo] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [avalLinked, setAvalLinked] = useState<any>(null);
|
||
const [showEditor, setShowEditor] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
if (ag?.id) HybridBackend.getAvaliacaoDoAgendamento(ag.id).then(a => { if (alive) setAvalLinked(a); });
|
||
return () => { alive = false; };
|
||
}, [ag?.id]);
|
||
|
||
const enviar = async (fn: () => Promise<any>, ok: string) => {
|
||
setSaving(true);
|
||
try {
|
||
const r = await fn();
|
||
if (r && r.success === false) { toast.error(r.message || 'ERRO NA OPERAÇÃO.'); return; }
|
||
toast.success(ok); onChanged(); close();
|
||
} catch { toast.error('ERRO NA OPERAÇÃO.'); } finally { setSaving(false); }
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-start gap-2.5">
|
||
<div className="p-2.5 bg-teal-50 rounded-xl shrink-0"><User size={20} className="text-teal-600" /></div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="text-base font-black text-gray-900 leading-tight uppercase tracking-tight truncate">{nome}</h3>
|
||
{ag.procedimento && (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-gray-600 uppercase bg-gray-50 px-2 py-0.5 rounded-full mt-1">
|
||
<Stethoscope size={11} className="text-teal-400" /> {ag.procedimento}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><span className="w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentCor }} />{dentNome}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{modo === '' ? (
|
||
<div className="space-y-2">
|
||
{avalLinked && avalLinked.status !== 'cancelada' && (
|
||
<button onClick={() => setShowEditor(true)}
|
||
className="w-full py-3 bg-violet-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-violet-700 transition-all flex items-center justify-center gap-1.5">
|
||
<ClipboardList size={15} /> {avalLinked.status === 'concluida' ? 'Ver avaliação' : 'Preencher avaliação'}
|
||
</button>
|
||
)}
|
||
<button onClick={() => { onAtender?.(); }}
|
||
className="w-full py-3 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all flex items-center justify-center gap-1.5">
|
||
<ClipboardCheck size={15} /> Atender
|
||
</button>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button onClick={() => { setModo('reencaminhar'); setMotivo(''); }}
|
||
className="py-2.5 rounded-xl border-2 border-gray-100 text-[11px] font-black uppercase text-gray-500 hover:border-amber-200 hover:text-amber-600 transition-all flex items-center justify-center gap-1.5">
|
||
<RotateCcw size={15} /> Reencaminhar
|
||
</button>
|
||
<button onClick={() => { setModo('avaliacao'); setMotivo(''); }}
|
||
className="py-2.5 rounded-xl border-2 border-gray-100 text-[11px] font-black uppercase text-gray-500 hover:border-teal-200 hover:text-teal-600 transition-all flex items-center justify-center gap-1.5">
|
||
<ClipboardList size={15} /> Solicitar avaliação
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-black text-gray-500 uppercase">
|
||
{modo === 'reencaminhar' ? 'Motivo do reencaminhamento' : 'O que precisa avaliar / motivo'}
|
||
</label>
|
||
<textarea autoFocus value={motivo} onChange={e => setMotivo(e.target.value)} rows={3}
|
||
placeholder={modo === 'reencaminhar' ? 'Ex.: não é minha especialidade, paciente pediu remarcar...' : 'Ex.: suspeita de cárie no 36, avaliar canal...'}
|
||
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-teal-400" />
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setModo('')} className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase text-gray-600 hover:bg-gray-50">Voltar</button>
|
||
<button disabled={saving} onClick={() => modo === 'reencaminhar'
|
||
? enviar(() => HybridBackend.reencaminharAgendamento(ag.id, motivo), 'REENCAMINHADO À CLÍNICA.')
|
||
: enviar(() => HybridBackend.solicitarAvaliacao(ag.id, motivo), 'AVALIAÇÃO SOLICITADA.')}
|
||
className="flex-[1.4] py-2.5 bg-teal-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-teal-700 disabled:opacity-50">
|
||
{saving ? 'ENVIANDO...' : 'Enviar'}
|
||
</button>
|
||
</div>
|
||
{modo === 'avaliacao' && <p className="text-[10px] text-gray-400">A recepção agendará o slot de avaliação; o odontograma e as fotos abrem no atendimento.</p>}
|
||
</div>
|
||
)}
|
||
{showEditor && avalLinked && (
|
||
<AvaliacaoEditor
|
||
avaliacaoId={avalLinked.id}
|
||
pacienteNome={nome}
|
||
onClose={() => setShowEditor(false)}
|
||
onSaved={() => { HybridBackend.getAvaliacaoDoAgendamento(ag.id).then(setAvalLinked); onChanged(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DetailPage: React.FC<Props> = (p) => {
|
||
const { push, close } = useStackModal();
|
||
const toast = useToast();
|
||
const ag = p.appointment || {};
|
||
const status = low(ag.status);
|
||
const provisorio = status === 'importado';
|
||
const inativo = ['cancelado', 'falta', 'remarcar', 'faltou'].includes(status);
|
||
const pacienteId = ag.pacienteid || ag.pacienteId || '';
|
||
const dentNome = ag.dentistaNome || p.dentists?.find(d => d.id === (ag.dentistaId || ag.dentistaid))?.nome || '—';
|
||
const dentCor = p.dentists?.find(d => d.id === (ag.dentistaId || ag.dentistaid))?.corAgenda;
|
||
|
||
const run = async (fn: () => Promise<any>, ok: string) => {
|
||
try { await fn(); toast.success(ok); p.onChanged(); close(); }
|
||
catch (e: any) { toast.error(e?.message || 'ERRO NA OPERAÇÃO.'); }
|
||
};
|
||
|
||
const nome = ag.pacienteNome || ag.pacientenome || '';
|
||
// Descrição = observações OU o texto longo (ex.: títulos dos eventos importados do Google).
|
||
const descricao = ag.observacoes || (nome.length > 40 ? nome : '');
|
||
|
||
// Dentista linkado por clínica: modal SIMPLES (sem agendamento/dados sensíveis/score/família).
|
||
if (p.dentistaRestrito) {
|
||
return <DentistaRestritoView ag={ag} nome={nome} dentNome={dentNome} dentCor={dentCor} onAtender={p.onAtender} onChanged={p.onChanged} />;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-start gap-2.5">
|
||
<div className="p-2.5 bg-teal-50 rounded-xl shrink-0"><User size={20} className="text-teal-600" /></div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="text-base font-black text-gray-900 leading-tight uppercase tracking-tight truncate">{nome}</h3>
|
||
<div className="flex items-center gap-2 flex-wrap mt-1">
|
||
<span className={`text-[10px] font-black uppercase px-2 py-0.5 rounded-full ${STATUS_COLOR[status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[status] || status || '—'}</span>
|
||
{pacienteId ? <ScoreBadge pacienteId={pacienteId} /> : null}
|
||
{ag.procedimento ? (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-gray-600 uppercase bg-gray-50 px-2 py-0.5 rounded-full">
|
||
<Stethoscope size={11} className="text-teal-400" /> {ag.procedimento}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{descricao ? (
|
||
<div>
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Descrição</label>
|
||
<div className="mt-1 text-[11px] text-gray-600 bg-gray-50 rounded-xl p-2.5 max-h-20 overflow-y-auto custom-scrollbar whitespace-pre-wrap break-words">{descricao}</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700">
|
||
<span className="w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentCor }} />{dentNome}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{pacienteId ? <FamiliaChips pacienteId={pacienteId} /> : null}
|
||
|
||
{/* Autoria */}
|
||
<div className="space-y-1 text-[10px] text-gray-400 font-medium">
|
||
{ag.created_by_nome && <p>Criado por <b className="text-gray-600">{ag.created_by_nome}</b></p>}
|
||
{ag.updated_by_nome && <p>Última alteração por <b className="text-gray-600">{ag.updated_by_nome}</b></p>}
|
||
{status === 'cancelado' && ag.canceled_by_nome && <p className="text-red-400">Cancelado por <b className="text-red-600">{ag.canceled_by_nome}</b></p>}
|
||
</div>
|
||
|
||
{/* Importado do Google (provisório): só confirmar/vincular ou remover. Não pontua. */}
|
||
{provisorio ? (
|
||
<div className="space-y-2">
|
||
<div className="bg-violet-50 border border-violet-100 rounded-xl p-3 text-[11px] text-violet-700 leading-snug">
|
||
Importado do Google. <b>Confirme o paciente</b> para virar um agendamento do sistema (aí passa a contar faltas/remarcações).
|
||
</div>
|
||
<button onClick={() => push({ title: 'CONFIRMAR PACIENTE', render: () => <ConfirmImportadoPage ag={ag} onConfirmed={p.onChanged} /> })}
|
||
className="w-full py-3 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all flex items-center justify-center gap-1.5">
|
||
<Link2 size={15} /> Confirmar paciente
|
||
</button>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
|
||
className="flex-1 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 text-xs font-black uppercase text-gray-600 flex items-center justify-center gap-1.5">
|
||
<History size={15} /> Histórico
|
||
</button>
|
||
<button onClick={() => { if (confirm('REMOVER ESTE IMPORTADO DA AGENDA?')) run(() => HybridBackend.excluirAgendamento(ag.id), 'IMPORTADO REMOVIDO.'); }}
|
||
className="flex-1 py-2.5 rounded-xl border-2 border-gray-100 text-xs font-black uppercase text-gray-500 hover:border-red-200 hover:text-red-600 transition-all flex items-center justify-center gap-1.5">
|
||
<Trash2 size={15} /> Remover
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (<>
|
||
{/* Ações de status */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button onClick={() => run(() => HybridBackend.atualizarAgendamento(ag.id, { status: 'confirmado' as any }), 'CONFIRMADO!')}
|
||
className="flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-black uppercase border-2 bg-white border-gray-100 text-gray-500 hover:border-emerald-200 hover:text-emerald-600 transition-all">
|
||
<CheckCircle2 size={15} /> Confirmar
|
||
</button>
|
||
<button onClick={() => run(() => HybridBackend.marcarFalta(ag.id), 'FALTA REGISTRADA.')}
|
||
className="flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-black uppercase border-2 bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-600 transition-all">
|
||
<AlertCircle size={15} /> Marcar falta
|
||
</button>
|
||
<button onClick={() => run(() => HybridBackend.remarcarAgendamento(ag.id), 'ENVIADO PARA "A REAGENDAR".')}
|
||
title="Sem data definida — vai para a lista 'A reagendar'"
|
||
className="flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-black uppercase border-2 bg-white border-gray-100 text-gray-500 hover:border-amber-200 hover:text-amber-600 transition-all">
|
||
<CalendarClock size={15} /> Reagendar depois
|
||
</button>
|
||
{inativo ? (
|
||
<button onClick={() => run(() => HybridBackend.restaurarAgendamento(ag.id), 'AGENDAMENTO RESTAURADO.')}
|
||
className="flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-black uppercase border-2 bg-white border-gray-100 text-gray-500 hover:border-teal-200 hover:text-teal-600 transition-all">
|
||
<RotateCcw size={15} /> Restaurar
|
||
</button>
|
||
) : (
|
||
<button onClick={() => { if (confirm('CANCELAR ESTE AGENDAMENTO?')) run(() => HybridBackend.excluirAgendamento(ag.id), 'AGENDAMENTO CANCELADO.'); }}
|
||
className="flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-black uppercase border-2 bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-600 transition-all">
|
||
<Ban size={15} /> Cancelar
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navegação / ações principais */}
|
||
<div className="space-y-2">
|
||
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
|
||
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
|
||
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
|
||
<span className="text-gray-300">›</span>
|
||
</button>
|
||
<div className="flex gap-2">
|
||
{p.podeAtender && (
|
||
<button onClick={() => { p.onAtender?.(); }}
|
||
className="flex-1 py-2.5 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all flex items-center justify-center gap-1.5">
|
||
<ClipboardCheck size={15} /> Atender
|
||
</button>
|
||
)}
|
||
<button onClick={() => { p.onEditar(); }}
|
||
title="Mover para um novo horário agora"
|
||
className="flex-1 py-2.5 bg-teal-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-teal-700 transition-all flex items-center justify-center gap-1.5">
|
||
<Pencil size={15} /> Mudar horário
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const AgendaDetailModal: React.FC<Props> = (props) => {
|
||
if (!props.appointment) return null;
|
||
const root: StackPage = { title: 'AGENDAMENTO', render: () => <DetailPage {...props} /> };
|
||
return <StackModal isOpen={props.isOpen} onClose={props.onClose} root={root} />;
|
||
};
|