// 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 && (