Files
scoreodonto.com/frontend/components/AgendaDetailModal.tsx
T
VPS 4 Builder 3003ffa2f4 ux(agenda): modal do evento sem rolagem; descricao em caixa rolavel
- compacta header/espacamentos/botoes (cabe sem scroll do modal)
- procedimento vira chip no header; caixa Descricao (observacoes/texto longo) com max-h + scroll proprio

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:09:11 +02:00

245 lines
14 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, useState } from 'react';
import { StackModal, StackPage, useStackModal } from './StackModal.tsx';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert
} 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;
}
// Cache simples na sessão do modal (evita refetch ao voltar/avançar).
const _cache: Record<string, { faltas?: number; familia?: { grupo: any; membros: any[] } }> = {};
const low = (s: any) => String(s || '').toLowerCase();
const STATUS_LABEL: Record<string, string> = {
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> = {
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-indigo-400">· {m.grupofamiliarrelacao}</span> : null}</>);
const cls = 'shrink-0 inline-flex items-center gap-1 bg-indigo-50 text-indigo-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-indigo-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>
);
};
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-blue-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-blue-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>
);
};
const DetailPage: React.FC<Props> = (p) => {
const { push, close } = useStackModal();
const toast = useToast();
const ag = p.appointment || {};
const status = low(ag.status);
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 : '');
return (
<div className="space-y-3">
<div className="flex items-start gap-2.5">
<div className="p-2.5 bg-blue-50 rounded-xl shrink-0"><User size={20} className="text-blue-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 ? <FaltasBadge 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-blue-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-blue-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-blue-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>
{/* 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-blue-200 hover:text-blue-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-blue-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-blue-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} />;
};