// 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; // Em acao de varios dias a hora sozinha nao diz de que dia e. const raDiaHora = iso => iso ? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : null; // Historico por dia (migration 049). A escala guarda so a jornada corrente; o // trigger arquiva cada check-in/out em escala_jornadas. Se a tabela nao // existir ou a leitura falhar, o relatorio segue so com a jornada corrente. async function raJornadasPorEscala(ids) { if (!ids.length) return {}; const { data, error } = await sb.from('escala_jornadas') .select('escala_id, data, check_in_em, check_out_em').in('escala_id', ids).order('data', { ascending: true }); if (error) { console.warn('[RelatorioAcao.jornadas] histórico indisponível:', error.message); return {}; } return (data || []).reduce((acc, j) => ({ ...acc, [j.escala_id]: [...(acc[j.escala_id] || []), j] }), {}); } // "09/10 08:55–17:02" por linha — um dia por jornada. const raJornadasTexto = jornadas => jornadas .map(j => raDiaHora(j.chegou) + (j.saiu ? '–' + raHora(j.saiu) : ' · sem check-out')) .join('\n'); 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_end_date, event_time, datas_especificas, 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 historico = await raJornadasPorEscala(lista.map(e => e.id)); const equipe = lista.map(e => { // Jornadas por dia: o historico + a corrente, quando ainda nao arquivada. const arquivadas = (historico[e.id] || []).map(j => ({ data: j.data, chegou: j.check_in_em, saiu: j.check_out_em })); const corrente = e.check_in_em && !arquivadas.some(j => j.chegou === e.check_in_em) ? [{ data: e.check_in_em.slice(0, 10), chegou: e.check_in_em, saiu: e.check_out_em }] : []; const jornadas = [...arquivadas, ...corrente].sort((a, b) => String(a.chegou).localeCompare(String(b.chegou))); const ultima = jornadas[jornadas.length - 1] || null; return { nome: (e.freelancers && e.freelancers.name) || 'Profissional', funcao: e.funcao || 'Promotor', status: e.status, // Sem check-in hoje (jornada ja virou), vale a ultima jornada registrada. chegou: e.check_in_em || (ultima && ultima.chegou) || null, saiu: e.check_in_em ? e.check_out_em : ((ultima && ultima.saiu) || null), jornadas, resultados: e.resultados || {}, observacao: e.observacao || null, materiais_devolvidos: e.materiais_devolvidos, problema: e.houve_problema ? (e.problema_desc || 'Sim') : null, }; }); const variosDias = equipe.some(p => p.jornadas.length > 1); // 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 todas = equipe.flatMap(p => p.jornadas); const chegadas = todas.map(j => j.chegou).filter(Boolean).sort(); const saidas = todas.map(j => j.saiu).filter(Boolean).sort(); const quando = erDatasDoEvento(acao); 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 || '', // Varios dias: todas as datas (datas especificas) ou "inicio a fim"; vazio = um dia so. datas_texto: quando.datas, local: acao.location || '', horario_previsto: quando.horarios || (quando.inicio ? quando.inicio + (quando.fim ? ' às ' + quando.fim : '') : ''), primeiro_checkin: chegadas[0] || null, ultimo_checkout: saidas[saidas.length - 1] || null, varios_dias: variosDias, 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, datas_texto: d.datas_texto || '', 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 (
{valor}
{rotulo}
); } // ================================================= // 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