// Acompanhamento em Tempo Real de UMA acao — orcamento 310, item 4. // // Ja existia o PainelPromotores (Freelancers > Acompanhamento), mas ele e // global: mostra as ultimas 100 escalas de todos os eventos. No dia da campanha // a producao nao quer isso — quer UMA acao na tela, sabendo quem chegou, quem // nao chegou e o que ja foi registrado. E esta a promessa do orcamento 310. // // Atualiza sozinho por realtime (mesmo canal da migration 028): quando a // promotora da check-in no celular, a linha muda aqui sem ninguem apertar F5. const ACOMP_STATUS_CORES = { 'Convidado': { bg: '#F1F5F9', fg: '#475569', rotulo: 'Aguardando' }, 'Confirmado': { bg: '#EFF6FF', fg: '#1D4ED8', rotulo: 'Confirmado' }, 'Recusado': { bg: '#FEF2F2', fg: '#B91C1C', rotulo: 'Recusou' }, 'Check-in': { bg: '#F0FDF4', fg: '#15803D', rotulo: 'Presente' }, 'Concluído': { bg: '#F0FDF4', fg: '#166534', rotulo: 'Concluiu' }, 'Falta': { bg: '#FEF2F2', fg: '#B91C1C', rotulo: 'Falta' }, }; const fmtAcompHora = iso => iso ? new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : null; function TileAcomp({ rotulo, valor, cor, destaque }) { return (
{valor}
{rotulo}
); } function AcompanhamentoEvento({ event }) { const [escalas, setEscalas] = React.useState([]); const [fotos, setFotos] = React.useState([]); const [historico, setHistorico] = React.useState({}); const [loading, setLoading] = React.useState(true); const [ocorrencias, setOcorrencias] = React.useState([]); const [fotoAberta, setFotoAberta] = React.useState(null); const [respostas, setRespostas] = React.useState({}); const carregar = React.useCallback(async () => { if (!event || !event.id) return; const [{ data: es, error: erEs }, { data: fo, error: erFo }, { data: oc }] = await Promise.all([ sb.from('event_escalas') .select('*, freelancers(id, name, phone, photo_url)') .eq('event_id', event.id) .order('created_at'), sb.from('escala_fotos') .select('*, freelancers(name)') .eq('event_id', event.id) .order('enviada_em', { ascending: false }), sb.from('escala_ocorrencias') .select('*, freelancers(name)') .eq('event_id', event.id) .order('criada_em', { ascending: false }), ]); if (erEs) console.error('[AcompanhamentoEvento.escalas] Supabase error:', erEs); if (erFo) console.error('[AcompanhamentoEvento.fotos] Supabase error:', erFo); setEscalas(es || []); setFotos(fo || []); setOcorrencias(oc || []); setLoading(false); // Historico basico por profissional (item 4 do orcamento): quantas acoes ja // concluiu e quantas faltou. Uma consulta so para a equipe inteira. const ids = (es || []).map(e => e.freelancer_id); if (ids.length) { const { data: hist } = await sb.from('event_escalas') .select('freelancer_id, status') .in('freelancer_id', ids) .neq('event_id', event.id); const mapa = {}; (hist || []).forEach(h => { if (!mapa[h.freelancer_id]) mapa[h.freelancer_id] = { concluidas: 0, faltas: 0 }; if (h.status === 'Concluído') mapa[h.freelancer_id].concluidas++; if (h.status === 'Falta' || h.status === 'Recusado') mapa[h.freelancer_id].faltas++; }); setHistorico(mapa); } }, [event && event.id]); React.useEffect(() => { carregar(); if (!event || !event.id) return; const canal = sb.channel('acomp-evento-' + event.id) .on('postgres_changes', { event: '*', schema: 'public', table: 'event_escalas', filter: 'event_id=eq.' + event.id }, carregar) .on('postgres_changes', { event: '*', schema: 'public', table: 'escala_fotos', filter: 'event_id=eq.' + event.id }, carregar) .on('postgres_changes', { event: '*', schema: 'public', table: 'escala_ocorrencias', filter: 'event_id=eq.' + event.id }, carregar) .subscribe(); return () => { sb.removeChannel(canal); }; }, [carregar, event && event.id]); const resumo = React.useMemo(() => { const r = { total: escalas.length, presentes: 0, semCheckin: 0, recusas: 0, concluidos: 0 }; escalas.forEach(e => { if (e.status === 'Recusado') r.recusas++; else if (e.check_in_em) { r.presentes++; if (e.check_out_em) r.concluidos++; } else r.semCheckin++; }); return r; }, [escalas]); // Consolida os indicadores da acao somando o que cada profissional registrou. const campos = (event && event.resultado_campos) || []; const totaisResultados = React.useMemo(() => { const t = {}; campos.forEach(c => { t[c.chave] = 0; }); escalas.forEach(e => { const r = e.resultados || {}; campos.forEach(c => { const v = parseFloat(r[c.chave]); if (Number.isFinite(v)) t[c.chave] += v; }); }); return t; }, [escalas, campos]); const ocorrenciasAbertas = ocorrencias.filter(o => !o.resolvida_em); // Responder e fechar em um passo so: a producao esta no meio da operacao, // nao vai abrir uma tela separada para cada problema. const resolver = async (oc) => { const texto = (respostas[oc.id] || '').trim(); const { error } = await sb.from('escala_ocorrencias').update({ resolvida_em: new Date().toISOString(), resposta: texto || null, }).eq('id', oc.id); if (error) { console.error('[AcompanhamentoEvento.resolver] Supabase error:', error); return; } carregar(); }; if (loading) return ; if (!escalas.length) { return ; } return (
{/* Resumo ao vivo */}
{/* Indicadores da acao */} {campos.length > 0 && (
Resultados registrados
{campos.map(c => { const feito = totaisResultados[c.chave] || 0; const meta = parseFloat(c.meta); const temMeta = Number.isFinite(meta) && meta > 0; const pct = temMeta ? Math.round((feito / meta) * 100) : null; return (
{feito.toLocaleString('pt-BR')} {temMeta && ( / {meta.toLocaleString('pt-BR')} )}
{c.label}
{pct !== null && (
= 100 ? DS.colors.success : DS.colors.textSec }}> {pct}% da meta
)}
); })}
)} {/* Equipe */}
Equipe da ação
{escalas.map(e => { const f = e.freelancers || {}; const cor = ACOMP_STATUS_CORES[e.status] || ACOMP_STATUS_CORES.Convidado; const h = historico[e.freelancer_id]; const dist = e.check_in_distancia_m != null ? fmtDistancia(Number(e.check_in_distancia_m)) : null; const linhaStatus = e.check_in_em ? 'Chegou ' + fmtAcompHora(e.check_in_em) + (dist ? ' · a ' + dist + ' do local' : '') : (e.status === 'Recusado' ? 'Recusou a escala' : 'Ainda não fez check-in'); return (
{e.check_in_foto_url ? ( setFotoAberta(e.check_in_foto_url)} style={{ width: 42, height: 42, borderRadius: 10, objectFit: 'cover', cursor: 'pointer', flexShrink: 0 }} /> ) : ( )}
{f.name || 'Sem nome'} · {e.funcao}
{linhaStatus} {e.check_out_em ? ' · saiu ' + fmtAcompHora(e.check_out_em) : ''}
{h && (h.concluidas || h.faltas) ? (
Histórico: {h.concluidas} concluídas · {h.faltas} faltas/recusas
) : null}
{cor.rotulo}
); })}
{/* Ocorrencias — o que a producao precisa ver primeiro */} {ocorrencias.length > 0 && (
Ocorrências ({ocorrenciasAbertas.length} em aberto)
{ocorrencias.map(o => (
{o.foto_url && ( setFotoAberta(o.foto_url)} style={{ width: 48, height: 48, borderRadius: 8, objectFit: 'cover', cursor: 'pointer', flexShrink: 0 }} /> )}
{o.categoria}
{o.descricao}
{(o.freelancers && o.freelancers.name) || '—'} · {fmtAcompHora(o.criada_em)} {o.endereco ? ' · ' + o.endereco : ''}
{o.resposta && (
Resposta: {o.resposta}
)}
{!o.resolvida_em && (
setRespostas(r => ({ ...r, [o.id]: v }))} placeholder="Resposta para o profissional (opcional)" /> resolver(o)}>Resolver
)}
))}
)} {/* Fotos recebidas */} {fotos.length > 0 && (
Fotos recebidas ({fotos.length})
{fotos.map(ft => (
setFotoAberta(ft.url)} style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 8, cursor: 'pointer', display: 'block' }} />
{ft.categoria || 'Outros'}
{(ft.freelancers && ft.freelancers.name) || ''} · {fmtAcompHora(ft.enviada_em)}
{ft.legenda && (
{ft.legenda}
)}
))}
)} {/* Lightbox — a producao amplia a foto de chegada quando o cliente cobra */} {fotoAberta && (
setFotoAberta(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.85)', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, cursor: 'zoom-out' }}>
)}
); } Object.assign(window, { AcompanhamentoEvento });