3003ffa2f4
- 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>
245 lines
14 KiB
TypeScript
245 lines
14 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 {
|
||
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} />;
|
||
};
|