dc5a2d89ae
Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB): - protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora, endereço, responsável, status. Ligado à OS. - POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica), POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[]. - UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na aba Monitoramento da OS (clínica e lab). Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
5.6 KiB
TypeScript
90 lines
5.6 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
|
const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); };
|
|
|
|
// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas.
|
|
export const LabAgendaView: React.FC = () => {
|
|
const toast = useToast();
|
|
const [lista, setLista] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [busy, setBusy] = useState('');
|
|
|
|
const carregar = useCallback(async () => {
|
|
setLoading(true);
|
|
try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); }
|
|
catch { /* silent */ } finally { setLoading(false); }
|
|
}, []);
|
|
useEffect(() => { carregar(); }, [carregar]);
|
|
|
|
const acao = async (id: string, status: 'realizada' | 'cancelada') => {
|
|
setBusy(id);
|
|
try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
|
};
|
|
|
|
// agrupa por dia (data_hora)
|
|
const grupos: Record<string, any[]> = {};
|
|
for (const c of lista.filter(x => x.status === 'agendada')) {
|
|
const k = (c.data_hora || '').slice(0, 10) || 'sem-data';
|
|
(grupos[k] = grupos[k] || []).push(c);
|
|
}
|
|
const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10);
|
|
|
|
const Card: React.FC<{ c: any }> = ({ c }) => {
|
|
const coleta = c.tipo === 'coleta';
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-start gap-3">
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 ${coleta ? 'bg-violet-600' : 'bg-blue-600'}`}>{coleta ? <Truck size={18} /> : <PackageCheck size={18} />}</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${coleta ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{coleta ? 'Coleta' : 'Entrega'}</span>
|
|
<span className="text-[11px] font-black text-gray-500">{dataHoraBR(c.data_hora)}</span>
|
|
</div>
|
|
<p className="font-bold text-gray-800 text-sm truncate mt-1">{c.os_tipo} {c.paciente_nome ? <span className="text-gray-400 font-medium">· {c.paciente_nome}</span> : ''}</p>
|
|
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}</p>
|
|
{c.endereco && <p className="text-[11px] text-gray-400 flex items-center gap-1 mt-0.5"><MapPin size={11} /> {c.endereco}</p>}
|
|
{c.responsavel && <p className="text-[11px] text-gray-400 flex items-center gap-1"><User size={11} /> {c.responsavel}</p>}
|
|
{c.status === 'agendada' && (
|
|
<div className="flex gap-2 mt-2">
|
|
<button disabled={busy === c.id} onClick={() => acao(c.id, 'realizada')} className="px-3 py-1.5 rounded-lg bg-[#2d6a4f] text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><CheckCircle2 size={12} /> Realizada</button>
|
|
<button disabled={busy === c.id} onClick={() => acao(c.id, 'cancelada')} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-[10px] font-black uppercase disabled:opacity-50">Cancelar</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 pb-10 max-w-2xl">
|
|
<PageHeader title="AGENDA DE COLETAS">
|
|
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
|
</PageHeader>
|
|
|
|
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
|
: lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma coleta ou entrega agendada.</div>
|
|
: (
|
|
<div className="space-y-5">
|
|
{Object.keys(grupos).sort().map(dia => (
|
|
<div key={dia}>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><CalendarClock size={12} /> {dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}</p>
|
|
<div className="space-y-2">{grupos[dia].map(c => <Card key={c.id} c={c} />)}</div>
|
|
</div>
|
|
))}
|
|
{realizadas.length > 0 && (
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-2">Realizadas (recentes)</p>
|
|
<div className="space-y-2 opacity-60">{realizadas.map(c => <Card key={c.id} c={c} />)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|