// 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}
); } // ===================================================================== // TIMELINE DA ACAO — orcamento 310, item 20 // ===================================================================== // Nao criei tabela nova. Tudo que a timeline conta ja esta gravado: check-in, // check-out, foto, ocorrencia, resultado. Uma tabela de "eventos da timeline" // seria um segundo lugar para a mesma verdade — e bastaria UMA escrita falhar // no celular com sinal ruim para a linha do tempo mentir sobre a operacao. // Aqui ela e derivada do que ja existe, entao nunca diverge do painel acima. // Fotos da mesma pessoa dentro dessa janela viram UMA linha ("enviou 5 fotos"). // Sem isso, uma promotora mandando antes/durante/depois enche a timeline e // esconde o check-in de quem chegou depois. const TL_JANELA_FOTOS_MS = 20 * 60 * 1000; function montarTimelineAcao(escalas, fotos, ocorrencias, campos) { const itens = []; const nomeDe = obj => (obj && obj.freelancers && obj.freelancers.name) || 'Profissional'; (escalas || []).forEach(e => { const nome = nomeDe(e); // confirmado_em guarda os dois lados: aceite e recusa (migration 038). if (e.confirmado_em) { if (e.status === 'Recusado') { itens.push({ at: e.confirmado_em, icone: 'x', cor: DS.colors.danger, titulo: nome + ' recusou a escala', detalhe: e.observacao ? 'Motivo: ' + e.observacao : null }); } else { itens.push({ at: e.confirmado_em, icone: 'check', cor: DS.colors.textSec, titulo: nome + ' confirmou presença', detalhe: e.funcao || null }); } } if (e.check_in_em) { const dist = e.check_in_distancia_m != null ? fmtDistancia(Number(e.check_in_distancia_m)) : null; itens.push({ at: e.check_in_em, icone: 'map', cor: DS.colors.success, titulo: nome + ' realizou check-in', detalhe: [e.funcao, dist ? 'a ' + dist + ' do local' : null].filter(Boolean).join(' · ') || null, galeria: e.check_in_foto_url ? [e.check_in_foto_url] : null }); } if (e.resultados_em) { const r = e.resultados || {}; const resumo = (campos || []).map(c => { const v = parseFloat(r[c.chave]); return Number.isFinite(v) ? v.toLocaleString('pt-BR') + ' ' + String(c.label || '').toLowerCase() : null; }).filter(Boolean).join(' · '); itens.push({ at: e.resultados_em, icone: 'trending', cor: DS.colors.primary, titulo: nome + ' registrou resultados', detalhe: resumo || null }); } if (e.check_out_em) { itens.push({ at: e.check_out_em, icone: 'clock', cor: DS.colors.purple, titulo: nome + ' realizou check-out', detalhe: e.houve_problema ? 'Relatou problema no encerramento' : (e.observacao || null), galeria: e.check_out_foto_url ? [e.check_out_foto_url] : null }); } }); (ocorrencias || []).forEach(o => { itens.push({ at: o.criada_em, icone: 'alert', cor: DS.colors.danger, titulo: 'Ocorrência registrada: ' + o.categoria, detalhe: nomeDe(o) + ' — ' + o.descricao, galeria: o.foto_url ? [o.foto_url] : null }); if (o.resolvida_em) { itens.push({ at: o.resolvida_em, icone: 'check', cor: DS.colors.success, titulo: 'Ocorrência resolvida: ' + o.categoria, detalhe: o.resposta ? 'Resposta: ' + o.resposta : null }); } }); // Fotos: agrupadas por profissional em janelas de tempo. const grupos = {}; (fotos || []).slice() .sort((a, b) => new Date(a.enviada_em) - new Date(b.enviada_em)) .forEach(f => { const lista = grupos[f.freelancer_id] || (grupos[f.freelancer_id] = []); const ultimo = lista[lista.length - 1]; const t = new Date(f.enviada_em).getTime(); if (ultimo && t - ultimo.fim <= TL_JANELA_FOTOS_MS) { ultimo.fim = t; ultimo.fotos.push(f); } else lista.push({ fim: t, fotos: [f] }); }); Object.keys(grupos).forEach(k => grupos[k].forEach(g => { const nome = nomeDe(g.fotos[0]); const cats = []; g.fotos.forEach(f => { const c = f.categoria || 'Outros'; if (cats.indexOf(c) === -1) cats.push(c); }); itens.push({ at: new Date(g.fim).toISOString(), icone: 'photo', cor: DS.colors.primary, titulo: nome + (g.fotos.length === 1 ? ' enviou 1 foto' : ' enviou ' + g.fotos.length + ' novas fotos'), detalhe: cats.join(' · '), galeria: g.fotos.map(f => f.url) }); })); return itens.sort((a, b) => new Date(b.at) - new Date(a.at)); } const fmtTlDia = iso => new Date(iso).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' }); const TL_PREVIA = 12; function TimelineAcao({ itens, onFoto }) { const [tudo, setTudo] = React.useState(false); const [asc, setAsc] = React.useState(false); if (!itens || !itens.length) return null; const ordenados = asc ? itens.slice().reverse() : itens; const visiveis = tudo ? ordenados : ordenados.slice(0, TL_PREVIA); let diaAnterior = null; return (
Timeline da ação ({itens.length})
{visiveis.map((it, i) => { const dia = fmtTlDia(it.at); const mostraDia = dia !== diaAnterior; diaAnterior = dia; const ultimo = i === visiveis.length - 1; return ( {mostraDia && (
{dia}
)}
{!ultimo &&
}
{fmtAcompHora(it.at)} {' — '}{it.titulo}
{it.detalhe && (
{it.detalhe}
)} {it.galeria && it.galeria.length > 0 && (
{it.galeria.slice(0, 6).map((u, j) => ( onFoto && onFoto(u)} style={{ width: 40, height: 40, borderRadius: 6, objectFit: 'cover', cursor: 'pointer' }} /> ))} {it.galeria.length > 6 && (
+{it.galeria.length - 6}
)}
)}
); })} {itens.length > TL_PREVIA && (
)}
); } 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); const timeline = React.useMemo( () => montarTimelineAcao(escalas, fotos, ocorrencias, campos), [escalas, fotos, ocorrencias, campos] ); // 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
)}
); })}
)} {/* Timeline da acao — o que aconteceu, na ordem */} {/* 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, TimelineAcao, montarTimelineAcao });