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