// 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)')
.order('id', { ascending: false });
if (error) console.error('[PromotorApp.carregar] Supabase error:', error);
setEscalas(data || []);
setLoading(false);
}, []);
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 360'}
{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 [enviandoFoto, setEnviandoFoto] = React.useState(false);
const [checkoutOpen, setCheckoutOpen] = React.useState(false);
const [co, setCo] = React.useState({ realizada: true, houve_problema: false, problema_desc: '', observacao: '' });
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));
sb.from('escala_fotos').select('*').eq('escala_id', escala.id).order('enviada_em', { ascending: false })
.then(({ data }) => setFotos(data || []));
}, [ev.id, escala.id]);
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 handleFoto = async (file) => {
if (!file) return;
setEnviandoFoto(true);
try {
const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
const path = `escala_${escala.id}/${crypto.randomUUID()}.${ext}`;
const { error: upErr } = await sb.storage.from('event-photos').upload(path, file, { contentType: file.type });
if (upErr) throw upErr;
const { data: { publicUrl } } = sb.storage.from('event-photos').getPublicUrl(path);
const { data, error } = await sb.from('escala_fotos').insert({
escala_id: escala.id,
event_id: ev.id,
freelancer_id: freelancer.id,
url: publicUrl,
storage_path: path,
}).select().single();
if (error) {
await sb.storage.from('event-photos').remove([path]); // rollback
throw error;
}
setFotos(prev => [data, ...prev]);
showToast('Foto enviada!');
} catch (err) {
console.error('[DetalheAcao.handleFoto] error:', err);
showToast('Erro ao enviar a foto.', 'danger');
}
setEnviandoFoto(false);
};
const confirmarCheckout = async () => {
const ok = await chamarRpc('promotor_check_out', {
p_escala_id: escala.id,
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,
}, '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 presença
)}
{jaConfirmou && !jaCheckIn && (
chamarRpc('promotor_check_in', { p_escala_id: escala.id }, 'Check-in registrado!')}>
Fazer check-in
)}
{jaCheckIn && !jaCheckOut && (
setCheckoutOpen(true)}>
Fazer check-out
)}
{jaCheckOut && (
Ação encerrada em {fmtPromoHora(escala.check_out_em)}
)}
{(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)}>}
)}
{fotos.length > 0 && (
Fotos enviadas ({fotos.length})
{fotos.map(f => (
))}
)}
{/* 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" />
)}
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 });