// App do Promotor — PWA mobile, dentro do proprio PROMO UP 360. Orcamento 290. // // O promotor ve SOMENTE as acoes em que esta escalado (RLS da migration 027). // Confirmar presenca / check-in / check-out passam pelas RPCs da migration 026, // nunca por UPDATE direto — senao ele conseguiria mudar o proprio cache. // // Boa parte do detalhe da acao (traje, briefing, cronograma, endereco // detalhado) vem do payload do Pedido de Producao (event_reports type='PP'), // que ja existe no sistema. Por isso nao criamos colunas novas em events. const fmtPromoMoney = v => 'R$ ' + (parseFloat(v) || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtPromoData = (iso) => { if (!iso) return '—'; const [y, m, d] = String(iso).split('-'); return (y && m && d) ? `${d}/${m}/${y}` : iso; }; const fmtPromoDataLonga = (iso) => { if (!iso) return '—'; // 'T00:00:00' evita o deslocamento de um dia por UTC (padrao do projeto). return new Date(iso + 'T00:00:00').toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' }); }; const fmtPromoHora = iso => iso ? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : null; function PromotorApp({ session, freelancer }) { const [escalas, setEscalas] = React.useState([]); const [loading, setLoading] = React.useState(true); const [abertaId, setAbertaId] = React.useState(null); const [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; const carregar = React.useCallback(async () => { const { data, error } = await sb .from('event_escalas') .select('*, events(id, name, client_name, event_date, event_end_date, event_time, location, coordenador_contato, local_lat, local_lng, raio_checkin_metros, resultado_campos)') .order('id', { ascending: false }); if (error) console.error('[PromotorApp.carregar] Supabase error:', error); setEscalas(data || []); setLoading(false); }, []); // O topo deste app e escuro, mas o resto do sistema tem barra branca. Sem // isto a status bar do Android fica branca colada num cabecalho azul. React.useEffect(() => { const tag = document.querySelector('meta[name="theme-color"]'); if (!tag) return; const anterior = tag.getAttribute('content'); tag.setAttribute('content', DS.colors.sidebar); return () => tag.setAttribute('content', anterior); }, []); React.useEffect(() => { carregar(); sb.rpc('promotor_registrar_login').then(() => {}); }, [carregar]); // Proximas primeiro; as sem data vao para o fim. const ordenadas = React.useMemo(() => { const copia = [...escalas]; copia.sort((a, b) => { const da = a.events?.event_date || '9999-12-31'; const db = b.events?.event_date || '9999-12-31'; return da.localeCompare(db); }); return copia; }, [escalas]); const hojeISO = new Date().toISOString().slice(0, 10); const proximas = ordenadas.filter(e => (e.events?.event_date || '9999-12-31') >= hojeISO); const passadas = ordenadas.filter(e => (e.events?.event_date || '9999-12-31') < hojeISO).reverse(); const aberta = escalas.find(e => e.id === abertaId) || null; const atualizarEscala = (nova) => setEscalas(prev => prev.map(e => e.id === nova.id ? { ...e, ...nova } : e)); const handleLogout = () => sb.auth.signOut(); return (
{/* Topo */}
{aberta ? ( ) : ( )}
{aberta ? 'Detalhe da ação' : `Olá, ${(freelancer?.name || '').split(' ')[0] || 'promotor'}!`}
{aberta ? (aberta.events?.name || '') : 'Suas ações no Promo Up'}
{loading ? : aberta ? : ( <> {escalas.length === 0 && ( )} {proximas.length > 0 && ( <> Próximas ações {proximas.map(e => setAbertaId(e.id)} />)} )} {passadas.length > 0 && ( <> Ações anteriores {passadas.map(e => setAbertaId(e.id)} />)} )} )}
{toast && setToast(null)} />}
); } function SecaoTitulo({ children }) { return (
{children}
); } function CardAcao({ escala, onOpen }) { const ev = escala.events || {}; return (
{ev.name || 'Ação'}
{ev.client_name || '—'}
{escala.status}
{fmtPromoData(ev.event_date)} {ev.event_time && ( {ev.event_time} )} {fmtPromoMoney(escala.cache)}
); } // ─────────────────────────────────────────────────────────── // Detalhe da acao + confirmar / check-in / fotos / check-out // ─────────────────────────────────────────────────────────── function DetalheAcao({ escala, freelancer, onChange, showToast }) { const ev = escala.events || {}; const [pp, setPp] = React.useState(null); const [fotos, setFotos] = React.useState([]); const [busy, setBusy] = React.useState(false); const [fotoOpen, setFotoOpen] = React.useState(false); const [resultadosOpen, setResultadosOpen] = React.useState(false); const [ocorrenciaOpen, setOcorrenciaOpen] = React.useState(false); const [ocorrencias, setOcorrencias] = React.useState([]); const [checkinOpen, setCheckinOpen] = React.useState(false); const [recusaOpen, setRecusaOpen] = React.useState(false); const [motivoRecusa, setMotivoRecusa] = React.useState(''); const [checkoutOpen, setCheckoutOpen] = React.useState(false); const [co, setCo] = React.useState({ realizada: true, houve_problema: false, problema_desc: '', observacao: '', materiais: 'Sim' }); const [coFoto, setCoFoto] = React.useState(null); const [coPreview, setCoPreview] = React.useState(''); // Supervisor e coordenador enxergam os registros da equipe toda (orcamento 310, // item 3). Quem decide de verdade e a policy do banco; isto so muda o filtro. const ehSupervisor = escala.funcao === 'Supervisor' || escala.funcao === 'Coordenador'; React.useEffect(() => { if (!ev.id) return; sb.from('event_reports').select('payload').eq('event_id', ev.id).eq('type', 'PP').maybeSingle() .then(({ data }) => setPp(data?.payload || null)); const consulta = ehSupervisor ? sb.from('escala_fotos').select('*, freelancers(name)').eq('event_id', ev.id) : sb.from('escala_fotos').select('*').eq('escala_id', escala.id); consulta.order('enviada_em', { ascending: false }).then(({ data }) => setFotos(data || [])); sb.from('escala_ocorrencias').select('*').eq('escala_id', escala.id) .order('criada_em', { ascending: false }) .then(({ data }) => setOcorrencias(data || [])); }, [ev.id, escala.id, ehSupervisor]); const chamarRpc = async (fn, args, msgOk) => { setBusy(true); const { data, error } = await sb.rpc(fn, args); setBusy(false); if (error) { console.error(`[DetalheAcao.${fn}] Supabase error:`, error); showToast(error.message || 'Não foi possível concluir.', 'danger'); return null; } const linha = Array.isArray(data) ? data[0] : data; if (linha) onChange(linha); showToast(msgOk); return linha; }; const confirmarCheckout = async () => { // Localizacao no check-out e best-effort: se o GPS falhar o encerramento // nao pode travar (a promotora pode ja estar a caminho do transporte). A // distancia fica gravada e a producao ve no painel. setBusy(true); let pos = null; try { pos = await obterPosicao(); } catch (err) { console.warn('[confirmarCheckout] sem GPS:', err.message); } const endereco = pos ? await enderecoAproximado(pos.lat, pos.lng) : null; // Foto de encerramento e opcional: se o celular travar na hora de sair, o // check-out nao pode ficar preso — a producao ja tem a foto de chegada. let fotoUrl = null, fotoPath = null; if (coFoto) { try { const ext = (coFoto.name.split('.').pop() || 'jpg').toLowerCase(); const caminho = 'checkout_' + escala.id + '/' + crypto.randomUUID() + '.' + ext; const { error: upErr } = await sb.storage.from('event-photos').upload(caminho, coFoto, { contentType: coFoto.type }); if (upErr) throw upErr; const { data: pub } = sb.storage.from('event-photos').getPublicUrl(caminho); fotoUrl = pub.publicUrl; fotoPath = caminho; } catch (err) { console.error('[confirmarCheckout] foto:', err); } } const ok = await chamarRpc('promotor_check_out', { p_escala_id: escala.id, p_lat: pos ? pos.lat : null, p_lng: pos ? pos.lng : null, p_precisao_m: pos ? pos.precisao : null, p_endereco: endereco, p_realizada: co.realizada, p_houve_problema: co.houve_problema, p_problema_desc: co.houve_problema ? (co.problema_desc || null) : null, p_observacao: co.observacao || null, p_materiais: co.materiais || null, p_foto_url: fotoUrl, p_foto_path: fotoPath, }, 'Check-out registrado. Obrigado!'); if (ok) setCheckoutOpen(false); }; const endereco = [ev.location, pp?.ponto_referencia, pp?.cidade_estado].filter(Boolean).join(' · '); const briefing = [pp?.objetivo, pp?.script, pp?.regras].filter(Boolean).join('\n\n'); const horario = pp?.horario_inicio ? `${pp.horario_inicio}${pp.horario_fim ? ' às ' + pp.horario_fim : ''}` : (ev.event_time || '—'); const linhas = [ { icon: 'crm', label: 'Cliente', valor: ev.client_name }, { icon: 'calendar', label: 'Data', valor: fmtPromoDataLonga(ev.event_date) }, { icon: 'clock', label: 'Horário', valor: horario }, { icon: 'map', label: 'Endereço', valor: endereco }, { icon: 'users', label: 'Traje', valor: pp?.dress_code }, { icon: 'phone', label: 'Coordenador', valor: ev.coordenador_contato || pp?.equipe?.coordenador?.nome }, { icon: 'wallet', label: 'Cachê', valor: fmtPromoMoney(escala.cache) + (escala.funcao ? ` · ${escala.funcao}` : '') }, { icon: 'budget', label: 'Prazo de pagamento', valor: escala.prazo_pagamento }, ].filter(l => l.valor); const jaConfirmou = !!escala.confirmado_em; const jaCheckIn = !!escala.check_in_em; const jaCheckOut = !!escala.check_out_em; return (
{ev.name || 'Ação'}
{escala.status}
{linhas.map(l => (
{l.label}
{l.valor}
))}
{briefing && (
Briefing
{briefing}
)} {/* Acoes */} {!jaConfirmou && !jaCheckOut && ( <> chamarRpc('promotor_confirmar_presenca', { p_escala_id: escala.id }, 'Presença confirmada!')}> Confirmar participação setRecusaOpen(true)}> Não posso participar )} {jaConfirmou && !jaCheckIn && ( setCheckinOpen(true)}> Fazer check-in )} {jaCheckIn && !jaCheckOut && ( setCheckoutOpen(true)}> Fazer check-out )} {/* Abrir localizacao — usa a coordenada cadastrada; se nao houver, cai no endereco em texto para o Maps resolver. */} {!jaCheckIn && (ev.local_lat != null || endereco) && ( window.open(ev.local_lat != null ? 'https://www.google.com/maps?q=' + ev.local_lat + ',' + ev.local_lng : 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(endereco), '_blank', 'noopener')}> Abrir localização )} {jaCheckOut && (
Ação encerrada em {fmtPromoHora(escala.check_out_em)}
)} {jaCheckIn && !jaCheckOut && (
setFotoOpen(true)}>Enviar foto setResultadosOpen(true)}>Resultados
)} {/* Ocorrencia — secao 14. Continua disponivel depois do check-out: problema com material costuma aparecer na hora de devolver. */} {jaCheckIn && ( setOcorrenciaOpen(true)}> Informar ocorrência )} {(escala.confirmado_em || escala.check_in_em) && (
{escala.confirmado_em && <>Confirmou {fmtPromoHora(escala.confirmado_em)}} {escala.check_in_em && <> · Check-in {fmtPromoHora(escala.check_in_em)}}
)}
{/* Prova de chegada — e isto que a producao mostra quando o cliente cobra */} {jaCheckIn && (
Registro de chegada
{escala.check_in_foto_url && ( {"Foto )}
Check-in {fmtPromoHora(escala.check_in_em)}
{escala.check_in_distancia_m != null && (
A {fmtDistancia(Number(escala.check_in_distancia_m))} do local da ação
)} {escala.check_in_endereco && (
{escala.check_in_endereco}
)}
)} {/* O promotor precisa ver que a ocorrencia dele foi lida e respondida — senao ele deixa de registrar e volta a resolver por telefone. */} {ocorrencias.length > 0 && (
Ocorrências registradas ({ocorrencias.length})
{ocorrencias.map(o => (
{o.categoria}
{o.resolvida_em ? 'Resolvida' : 'Em aberto'}
{o.descricao}
{fmtPromoHora(o.criada_em)}
{o.resposta && (
Produção: {o.resposta}
)}
))}
)} {fotos.length > 0 && (
{ehSupervisor ? 'Registros da equipe' : 'Fotos enviadas'} ({fotos.length})
{fotos.map(f => (
{""}
{f.categoria || 'Outros'}
{ehSupervisor && f.freelancers && (
{f.freelancers.name}
)}
))}
)} setOcorrenciaOpen(false)} onRegistrada={nova => setOcorrencias(prev => [nova, ...prev])} showToast={showToast} /> setFotoOpen(false)} onEnviada={nova => setFotos(prev => [nova, ...prev])} showToast={showToast} /> setResultadosOpen(false)} onSalvo={onChange} showToast={showToast} /> {/* Nao posso participar — secao 5 da especificacao. O motivo e opcional: exigir justificativa faz o promotor simplesmente nao responder. */} setRecusaOpen(false)} title="Não posso participar" width={400}>
A produção será avisada na hora e poderá chamar outro profissional.
setRecusaOpen(false)}>Voltar { const ok = await chamarRpc('promotor_recusar_escala', { p_escala_id: escala.id, p_motivo: motivoRecusa.trim() || null }, 'Recusa registrada. Obrigado por avisar!'); if (ok) setRecusaOpen(false); }}> Confirmar recusa
setCheckinOpen(false)} onDone={onChange} showToast={showToast} /> {/* Check-out — formulario curto */} setCheckoutOpen(false)} title="Check-out" width={420}>
A ação foi realizada?
setCo(c => ({ ...c, realizada: true }))}>Sim setCo(c => ({ ...c, realizada: false }))}>Não
Houve algum problema?
setCo(c => ({ ...c, houve_problema: false }))}>Não setCo(c => ({ ...c, houve_problema: true }))}>Sim
{co.houve_problema && ( setCo(c => ({ ...c, problema_desc: v }))} placeholder="Descreva rapidamente" /> )}
Todos os materiais foram entregues/devolvidos corretamente?
{['Sim', 'Não', 'Não se aplica'].map(op => ( setCo(c => ({ ...c, materiais: op }))}> {op} ))}
Foto de encerramento (opcional)
setCo(c => ({ ...c, observacao: v }))} placeholder="Algo que a produção precisa saber" />
setCheckoutOpen(false)}>Cancelar {busy ? 'Enviando…' : 'Encerrar ação'}
); } Object.assign(window, { PromotorApp });