// 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 ? (
setAbertaId(null)}
style={{ border: 'none', background: 'rgba(255,255,255,0.12)', color: '#fff', width: 34, height: 34, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
) : (
)}
{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}
{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 && (
)}
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}>
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}
))}
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 });