// Relatorio Automatico da Acao (RA) — orcamento 310, Fase 3, item 24.
//
// O QUE MUDA EM RELACAO AOS OUTROS RELATORIOS
// O Pedido de Producao e o Relatorio Fotografico sao formularios: alguem
// senta e preenche. Este nasce pronto. Tudo que a acao ja gravou — quem
// chegou, a que horas, quantas fotos, quais indicadores, que ocorrencias —
// ja esta no banco desde a Fase 1. O relatorio so junta.
//
// MAS OS CAMPOS DE JULGAMENTO CONTINUAM EDITAVEIS
// Objetivo, materiais, observacoes e resultado sao texto que alguem assina.
// O sistema chuta um comeco a partir do que aconteceu, e a producao corrige
// antes de mandar para o cliente. Relatorio que sai do sistema direto para o
// cliente sem ninguem ler e um acidente esperando a primeira campanha ruim.
//
// POR QUE NAO REGERA SOZINHO AO REABRIR
// Se o relatorio ja foi salvo, abrir de novo carrega o que foi salvo — e nao
// os dados frescos. Regerar por cima apagaria o texto que a producao
// escreveu, calado, e ninguem editaria duas vezes. Para trazer numeros novos
// existe um botao que diz o que vai fazer, e ele nao toca nos campos de
// texto.
const RA_FOTOS_NO_PDF = 12;
const raHora = iso => iso
? new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
: null;
const raNum = v => {
const n = parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) ? n : null;
};
// =================================================
// COLETA — o relatorio a partir do que a operacao gravou
// =================================================
async function coletarDadosDaAcao(event) {
const [{ data: ev }, { data: escalas }, { data: fotos }, { data: ocorrencias }, { data: pp }] =
await Promise.all([
sb.from('events').select('id, name, client_name, event_date, event_time, location, resultado_campos')
.eq('id', event.id).maybeSingle(),
sb.from('event_escalas')
.select('id, funcao, status, check_in_em, check_out_em, realizada, houve_problema, problema_desc, observacao, materiais_devolvidos, resultados, freelancers(name)')
.eq('event_id', event.id),
sb.from('escala_fotos').select('id, url, categoria, legenda, enviada_em, freelancers(name)')
.eq('event_id', event.id).order('enviada_em', { ascending: true }),
sb.from('escala_ocorrencias').select('id, categoria, descricao, criada_em, resolvida_em, resposta')
.eq('event_id', event.id).order('criada_em', { ascending: true }),
sb.from('event_reports').select('payload').eq('event_id', event.id).eq('type', 'PP').maybeSingle(),
]);
const acao = ev || event;
const campos = acao.resultado_campos || [];
const lista = escalas || [];
const equipe = lista.map(e => ({
nome: (e.freelancers && e.freelancers.name) || 'Profissional',
funcao: e.funcao || 'Promotor',
status: e.status,
chegou: e.check_in_em,
saiu: e.check_out_em,
resultados: e.resultados || {},
observacao: e.observacao || null,
materiais_devolvidos: e.materiais_devolvidos,
problema: e.houve_problema ? (e.problema_desc || 'Sim') : null,
}));
// Soma por indicador. Vale a pena somar, e nao so listar por pessoa: o
// cliente pergunta "quantas abordagens teve a acao", nao "quantas a Maria fez".
const indicadores = campos.map(c => {
let total = 0, pessoas = 0;
equipe.forEach(p => {
const v = raNum(p.resultados[c.chave]);
if (v !== null) { total += v; pessoas++; }
});
return { chave: c.chave, label: c.label, meta: c.meta || null, total, pessoas };
});
const chegadas = equipe.map(p => p.chegou).filter(Boolean).sort();
const saidas = equipe.map(p => p.saiu).filter(Boolean).sort();
const presentes = equipe.filter(p => p.chegou).length;
const devolveram = equipe.filter(p => p.materiais_devolvidos === 'Sim').length;
const comCheckout = equipe.filter(p => p.saiu).length;
// Observacoes: o que a equipe escreveu no check-out, com o nome de quem
// escreveu. Jogar tudo num paragrafo anonimo perderia a informacao de quem
// viu o que.
const obsEquipe = equipe
.filter(p => p.observacao || p.problema)
.map(p => p.nome + ': ' + [p.problema, p.observacao].filter(Boolean).join(' — '))
.join('\n');
const resumo = [
presentes + ' de ' + equipe.length + (equipe.length === 1 ? ' profissional presente' : ' profissionais presentes') + '.',
indicadores.filter(i => i.pessoas > 0)
.map(i => i.label + ': ' + i.total + (i.meta ? ' de ' + i.meta + ' (' + Math.round(i.total / i.meta * 100) + '%)' : ''))
.join(' · '),
(fotos || []).length ? (fotos.length + (fotos.length === 1 ? ' foto registrada.' : ' fotos registradas.')) : '',
(ocorrencias || []).length ? (ocorrencias.length + (ocorrencias.length === 1 ? ' ocorrência registrada.' : ' ocorrências registradas.')) : 'Sem ocorrências.',
].filter(Boolean).join(' ');
return {
gerado_em: new Date().toISOString(),
cliente: acao.client_name || '',
acao: acao.name || '',
data: acao.event_date || '',
local: acao.location || '',
horario_previsto: acao.event_time || '',
primeiro_checkin: chegadas[0] || null,
ultimo_checkout: saidas[saidas.length - 1] || null,
objetivo: (pp && pp.payload && pp.payload.objetivo) || '',
equipe,
indicadores,
materiais: comCheckout
? devolveram + ' de ' + comCheckout + ' confirmaram a conferência de materiais no check-out.'
: '',
fotos: (fotos || []).map(f => ({
url: f.url, categoria: f.categoria || 'Outros', legenda: f.legenda || '',
autor: (f.freelancers && f.freelancers.name) || '', hora: f.enviada_em,
})),
ocorrencias: (ocorrencias || []).map(o => ({
categoria: o.categoria, descricao: o.descricao, hora: o.criada_em,
resposta: o.resposta || '', resolvida: !!o.resolvida_em,
})),
observacoes: obsEquipe,
resultado: resumo,
};
}
// Os quatro campos que uma pessoa assina. A regeracao nunca os toca.
const RA_CAMPOS_DE_TEXTO = ['objetivo', 'materiais', 'observacoes', 'resultado'];
// =================================================
// PUBLICACAO NO PORTAL DO CLIENTE — orcamento 310, item 25
// =================================================
// O orcamento e explicito: o cliente nao pode ver cache, telefone do
// profissional nem ocorrencia administrativa nao autorizada.
//
// Por isso publicar NAO e marcar um sinalizador no relatorio interno. RLS e
// por LINHA, nao por chave do JSON: se o cliente pudesse ler a linha, leria o
// payload inteiro pela API, mostre a tela o que mostrar. Publicar e gravar uma
// COPIA em relatorios_publicados contendo so o que foi liberado.
//
// A copia e montada campo a campo, de proposito. Um espalhamento do payload
// interno funcionaria hoje e vazaria no dia em que alguem acrescentasse um
// campo novo ao relatorio sem lembrar desta tela.
function montarPayloadDoCliente(d, escolhas) {
const cliente = {
acao: d.acao,
cliente: d.cliente,
data: d.data,
local: d.local,
horario_previsto: d.horario_previsto,
// O texto do resultado E o relatorio para o cliente: sempre vai.
resultado: d.resultado || '',
publicado_em: new Date().toISOString(),
};
if (escolhas.objetivo) cliente.objetivo = d.objetivo || '';
if (escolhas.materiais) cliente.materiais = d.materiais || '';
if (escolhas.observacoes) cliente.observacoes = d.observacoes || '';
if (escolhas.indicadores) {
cliente.indicadores = d.indicadores.filter(i => i.pessoas > 0)
.map(i => ({ label: i.label, total: i.total, meta: i.meta }));
}
if (escolhas.fotos) {
// Sem o autor: o nome de quem fotografou e informacao da operacao, e a
// lista do que o cliente ve nao inclui a equipe.
cliente.fotos = d.fotos.map(f => ({ url: f.url, categoria: f.categoria, legenda: f.legenda }));
}
if (escolhas.equipe) {
// Quantos e em que horario. Sem nome, sem funcao individual, sem cache.
cliente.equipe_resumo = {
total: d.equipe.length,
presentes: d.equipe.filter(p => p.chegou).length,
primeiro_checkin: d.primeiro_checkin,
ultimo_checkout: d.ultimo_checkout,
};
}
// Ocorrencia so vai uma a uma, marcada a dedo. O padrao e nao ir.
const marcadas = escolhas.ocorrencias || {};
const liberadas = d.ocorrencias.filter((_, i) => marcadas[i]);
if (liberadas.length) {
cliente.ocorrencias = liberadas.map(o => ({
categoria: o.categoria, descricao: o.descricao, resolvida: o.resolvida,
}));
}
return cliente;
}
// eventId vem por fora, e nao de dentro do payload: relatorios salvos antes
// desta tela nao carregam o id da acao, e ler dali daria undefined calado.
function RAPublicarModal({ open, eventId, data, publicado, onClose, onPublicado, showToast }) {
const [escolhas, setEscolhas] = React.useState({
objetivo: true, indicadores: true, fotos: true,
materiais: true, observacoes: false, equipe: false, ocorrencias: {},
});
const [busy, setBusy] = React.useState(false);
if (!open) return null;
const marcarOcorrencia = (i, v) =>
setEscolhas(e => ({ ...e, ocorrencias: { ...e.ocorrencias, [i]: v } }));
const publicar = async () => {
setBusy(true);
const payload = montarPayloadDoCliente(data, escolhas);
const { data: sessao } = await sb.auth.getUser();
const uid = sessao && sessao.user ? sessao.user.id : null;
let nome = null;
if (uid) {
const { data: u } = await sb.from('app_users').select('name, email').eq('id', uid).maybeSingle();
nome = u ? (u.name || u.email) : null;
}
// onConflict no event_id: republicar corrige, nao empilha.
const { error } = await sb.from('relatorios_publicados').upsert({
event_id: eventId,
payload,
publicado_em: new Date().toISOString(),
publicado_por: uid,
publicado_por_nome: nome,
}, { onConflict: 'event_id' });
if (error) {
setBusy(false);
console.error('[RAPublicarModal] Supabase error:', error);
showToast('Não consegui publicar: ' + error.message, 'danger');
return;
}
// Entra na linha do tempo da acao no portal. Best-effort: se falhar, o
// relatorio ja esta publicado e derrubar a operacao por causa do aviso
// seria trocar o essencial pelo acessorio.
const { error: errAviso } = await sb.from('operation_updates').insert({
event_id: eventId, type: 'nota',
text: 'Relatório da ação publicado.', created_by_name: nome,
});
if (errAviso) console.warn('[RAPublicarModal] aviso não registrado:', errAviso.message);
setBusy(false);
showToast('Relatório publicado no Portal do Cliente.');
onPublicado();
onClose();
};
return (
Só o que estiver marcado é gravado na versão do cliente. O que não for
marcado não fica escondido — não é enviado.
setEscolhas(e => ({ ...e, objetivo: v }))} />
i.pessoas > 0).map(i => i.label).join(', ')}
checked={escolhas.indicadores} onChange={v => setEscolhas(e => ({ ...e, indicadores: v }))} />
setEscolhas(e => ({ ...e, fotos: v }))} />
setEscolhas(e => ({ ...e, materiais: v }))} />
setEscolhas(e => ({ ...e, observacoes: v }))} />
setEscolhas(e => ({ ...e, equipe: v }))} />
{data.ocorrencias.length > 0 && (
Ocorrências — nenhuma vai por padrão
{data.ocorrencias.map((o, i) => (
marcarOcorrencia(i, v)} />
))}
)}
Nunca vão, marcados ou não: cachê, telefone e dados bancários da equipe.
{publicado ? ' Republicar substitui a versão que o cliente vê hoje.' : ''}
Cancelar
{busy ? 'Publicando...' : (publicado ? 'Republicar' : 'Publicar')}
);
}
function RAResumoNumero({ valor, rotulo }) {
return (
);
}
// =================================================
// RELATORIO DA ACAO (RA)
// =================================================
function RAReport({ event, onClose, onSaved, showToast }) {
const [data, setData] = React.useState(null);
const [reportId, setReportId] = React.useState(null);
const [publicado, setPublicado] = React.useState(null);
const [publicarOpen, setPublicarOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [exporting, setExporting] = React.useState(false);
const [atualizando, setAtualizando] = React.useState(false);
const pdfRef = React.useRef(null);
React.useEffect(() => {
(async () => {
const { data: salvo } = await sb.from('event_reports')
.select('id, payload').eq('event_id', event.id).eq('type', 'RA').maybeSingle();
if (salvo && salvo.payload) {
// Ja existe: vale o que foi salvo. Ver "POR QUE NAO REGERA SOZINHO".
setReportId(salvo.id);
setData(salvo.payload);
} else {
setData(await coletarDadosDaAcao(event));
}
const { data: pub } = await sb.from('relatorios_publicados')
.select('publicado_em, publicado_por_nome').eq('event_id', event.id).maybeSingle();
setPublicado(pub || null);
setLoading(false);
})();
}, [event.id]);
const setCampo = (campo, valor) => setData(d => ({ ...d, [campo]: valor }));
// Traz numeros novos e preserva o texto escrito por gente.
const atualizarDaOperacao = async () => {
setAtualizando(true);
const fresco = await coletarDadosDaAcao(event);
setData(d => {
const mantidos = {};
RA_CAMPOS_DE_TEXTO.forEach(c => { if (d[c]) mantidos[c] = d[c]; });
return { ...fresco, ...mantidos };
});
setAtualizando(false);
};
const salvar = async () => {
setSaving(true);
let err;
if (reportId) {
const res = await sb.from('event_reports').update({ payload: data }).eq('id', reportId);
err = res.error;
} else {
const { data: row, error } = await sb.from('event_reports')
.insert({ event_id: event.id, type: 'RA', payload: data }).select().single();
if (row) setReportId(row.id);
err = error;
}
setSaving(false);
if (err) {
console.error('[RAReport.salvar] Supabase error:', err);
alert('Erro ao salvar o relatório: ' + err.message);
return false;
}
if (onSaved) onSaved();
return true;
};
const exportarPDF = async () => {
setExporting(true);
await salvar();
const nome = (event.name || 'acao').replace(/[^a-z0-9-_\s]/gi, '').trim().replace(/\s+/g, '_');
await renderPDF(pdfRef.current, `Relatorio_${nome}.pdf`);
setExporting(false);
};
if (loading || !data) {
return (
);
}
const presentes = data.equipe.filter(p => p.chegou).length;
return (
{data.indicadores.filter(i => i.pessoas > 0).map(i => (
))}
Números coletados da operação. Os textos abaixo são seus — a atualização não mexe neles.
{atualizando ? 'Atualizando...' : 'Atualizar com os dados da operação'}
Objetivo
setPublicarOpen(false)}
onPublicado={() => setPublicado({ publicado_em: new Date().toISOString(), publicado_por_nome: null })}
showToast={showToast || (m => alert(m))}
/>
{/* Fora da tela: e o que o html2pdf fotografa. */}
);
}
// =================================================
// PDF
// =================================================
const RAPDFTemplate = React.forwardRef(({ data }, ref) => {
const presentes = data.equipe.filter(p => p.chegou).length;
const fotos = data.fotos.slice(0, RA_FOTOS_NO_PDF);
return (
Relatório da Ação
{data.acao}
| Profissional |
Função |
Entrada |
Saída |
{data.equipe.map((p, i) => (
| {p.nome} |
{p.funcao} |
{raHora(p.chegou) || (p.status === 'Falta' ? 'Faltou' : '—')} |
{raHora(p.saiu) || '—'} |
))}
{data.indicadores.some(i => i.pessoas > 0) && (
<>
| Indicador |
Total |
Meta |
Atingido |
{data.indicadores.filter(i => i.pessoas > 0).map(i => (
| {i.label} |
{i.total} |
{i.meta || '—'} |
{i.meta ? Math.round(i.total / i.meta * 100) + '%' : '—'} |
))}
>
)}
{data.ocorrencias.length === 0
?
: (
| Hora |
Categoria |
Descrição |
Situação |
{data.ocorrencias.map((o, i) => (
| {raHora(o.hora)} |
{o.categoria} |
{o.descricao}{o.resposta ? ' — Produção: ' + o.resposta : ''} |
{o.resolvida ? 'Resolvida' : 'Em aberto'} |
))}
)}
{fotos.length > 0 && (
<>
{fotos.map((f, i) => (
// pdf-avoid-break: sem isto a legenda cai numa pagina e a foto
// fica na outra, o mesmo problema que o Relatorio Fotografico
// teve antes da correcao do 9bf5951.
{f.categoria}
{f.legenda &&
{f.legenda}
}
{[f.autor, raHora(f.hora)].filter(Boolean).join(' · ')}
))}
>
)}
Gerado pelo Promo Up 360 em {new Date(data.gerado_em).toLocaleString('pt-BR')}
);
});
Object.assign(window, { RAReport, RAPDFTemplate, coletarDadosDaAcao });