// Painel da Producao — as acoes de HOJE, orcamento 310 item 21. // // O Dashboard responde o mes: quanto foi faturado, quantos eventos, quais // aniversarios. No dia da campanha a producao nao precisa disso — precisa // saber quais acoes estao acontecendo AGORA e qual delas esta com problema. // Por isso este painel entra no topo do Dashboard e some sozinho nos dias // sem acao: um card vazio todo dia treina a pessoa a ignorar o topo da tela. // // Os alertas do item 22 nascem aqui porque sao a mesma conta: quem nao // confirmou, quem nao chegou, quem abriu ocorrencia. Calcular isso duas vezes // em telas diferentes seria a garantia de que um dia elas discordariam. const PDD_MIN = 60 * 1000; const PDD_HORA = 60 * PDD_MIN; // Aviso antecipado: 30 minutos antes de comecar ja da tempo de acionar alguem. const PDD_JANELA_ANTES_MS = 30 * PDD_MIN; // Turno impossivel — quem esta com check-in aberto ha esse tempo esqueceu o // check-out. Antes disso a pessoa pode simplesmente ainda estar trabalhando. const PDD_CHECKOUT_ESQUECIDO_MS = 12 * PDD_HORA; const PDD_ESTADOS = { andamento: { emoji: '🟢', rotulo: 'Em andamento', cor: '#15803D', fundo: '#F0FDF4' }, atencao: { emoji: '🟡', rotulo: 'Atenção', cor: '#B45309', fundo: '#FFFBEB' }, aguardando: { emoji: '⚪', rotulo: 'Aguardando', cor: '#475569', fundo: '#F8FAFC' }, encerrada: { emoji: '✅', rotulo: 'Encerrada', cor: '#166534', fundo: '#F0FDF4' }, }; const pddDataLocalISO = (d = new Date()) => { const p = n => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); }; // event_time e texto livre ("14:00", "14h", "14:00 as 18:00"). Pego a primeira // hora que aparecer; sem hora nenhuma, o painel simplesmente nao fala de atraso. function pddInicioDoEvento(ev) { if (!ev || !ev.event_date) return null; const bruto = (ev.event_time || '').trim(); const m = bruto.match(/(\d{1,2})\s*[:h]\s*(\d{2})?/); if (!m) return null; const hora = parseInt(m[1], 10); const min = m[2] ? parseInt(m[2], 10) : 0; if (hora > 23 || min > 59) return null; const partes = String(ev.event_date).slice(0, 10).split('-').map(Number); if (partes.length !== 3 || partes.some(n => !n)) return null; return new Date(partes[0], partes[1] - 1, partes[2], hora, min, 0, 0); } const pddMinutos = ms => Math.max(1, Math.round(Math.abs(ms) / PDD_MIN)); function pddTextoTempo(ms) { const min = pddMinutos(ms); if (min < 60) return min + ' min'; const h = Math.floor(min / 60); const resto = min % 60; return h + 'h' + (resto ? String(resto).padStart(2, '0') : ''); } // Resumo + alertas de UMA acao. Recebe agora como parametro para a tela inteira // enxergar o mesmo instante — sem isso duas linhas podem discordar em segundos. function resumirAcaoDoDia(evento, escalas, fotos, ocorrencias, agora) { const doEvento = (escalas || []).filter(e => e.event_id === evento.id); const alertas = []; const inicio = pddInicioDoEvento(evento); const jaComecou = inicio ? agora >= inicio.getTime() : false; const perto = inicio ? (!jaComecou && (inicio.getTime() - agora) <= PDD_JANELA_ANTES_MS) : false; let checkins = 0, confirmados = 0, recusas = 0, encerrados = 0, ausentes = 0; doEvento.forEach(e => { const nome = (e.freelancers && e.freelancers.name) || 'Profissional'; if (e.status === 'Recusado') { recusas++; alertas.push({ tipo: 'recusa', texto: nome + ' recusou a escala', detalhe: e.observacao ? 'Motivo: ' + e.observacao : 'Falta repor a vaga' }); return; } if (e.check_in_em) { checkins++; confirmados++; if (e.check_out_em) encerrados++; else if (agora - new Date(e.check_in_em).getTime() > PDD_CHECKOUT_ESQUECIDO_MS) { alertas.push({ tipo: 'checkout', texto: nome + ' não realizou check-out', detalhe: 'Entrou há ' + pddTextoTempo(agora - new Date(e.check_in_em).getTime()) }); } return; } const naoConfirmou = e.status === 'Convidado'; if (!naoConfirmou) confirmados++; // Um alerta por pessoa. Quem nao confirmou E nao chegou gerava duas linhas // sobre o mesmo problema — na hora da operacao isso so empurra o proximo // nome para fora da tela. // // Antes de comecar, quem ja confirmou e simplesmente nao chegou ainda NAO e // alerta: seria pintar de amarelo toda acao do dia meia hora antes, e um // aviso que aparece sempre deixa de ser aviso. if (jaComecou) { ausentes++; alertas.push({ tipo: 'checkin', texto: nome + ' ainda não realizou check-in', detalhe: 'Ação começou há ' + pddTextoTempo(agora - inicio.getTime()) + (naoConfirmou ? ' · também não confirmou a escala' : '') }); } else if (naoConfirmou) { // Perto do inicio, "nao confirmou" deixa de ser pendencia e vira risco de // furo na equipe — por isso conta como problema e pinta a acao. alertas.push({ tipo: perto ? 'checkin' : 'confirmacao', texto: nome + ' ainda não confirmou a escala', detalhe: inicio ? 'Ação começa em ' + pddTextoTempo(inicio.getTime() - agora) : null }); } }); const fotosDoEvento = (fotos || []).filter(f => f.event_id === evento.id).length; const abertas = (ocorrencias || []).filter(o => o.event_id === evento.id && !o.resolvida_em); abertas.forEach(o => alertas.push({ tipo: 'ocorrencia', texto: 'Ocorrência aberta: ' + o.categoria, detalhe: o.descricao })); const escalados = doEvento.length; const ativos = escalados - recusas; const temProblema = alertas.some(a => a.tipo !== 'confirmacao'); let estado = 'aguardando'; if (checkins > 0 && ativos > 0 && encerrados === checkins && jaComecou) estado = 'encerrada'; else if (checkins > 0) estado = 'andamento'; if (temProblema && estado !== 'encerrada') estado = 'atencao'; if (estado === 'encerrada' && abertas.length) estado = 'atencao'; return { evento, estado, escalados, confirmados, checkins, recusas, encerrados, ausentes, fotos: fotosDoEvento, ocorrenciasAbertas: abertas.length, alertas, inicio, }; } function PDDNumero({ valor, rotulo, cor }) { return ( {valor} {rotulo} ); } function PainelDoDia({ onAbrirAcao }) { const [eventos, setEventos] = React.useState([]); const [escalas, setEscalas] = React.useState([]); const [fotos, setFotos] = React.useState([]); const [ocorrencias, setOcorrencias] = React.useState([]); const [carregando, setCarregando] = React.useState(true); // O relogio anda: "começa em 15 min" precisa virar "começou há 5 min" sozinho. const [agora, setAgora] = React.useState(() => Date.now()); const carregar = React.useCallback(async () => { const hoje = pddDataLocalISO(); const { data: evs, error } = await sb.from('events') .select('id, name, client_name, event_date, event_time, location, status') .eq('event_date', hoje); if (error) { console.error('[PainelDoDia.eventos] Supabase error:', error); setCarregando(false); return; } // Acao cancelada nao tem operacao para acompanhar — e a varredura de // alertas (migration 042) tambem a ignora. Se uma das duas contasse e a // outra nao, o painel e o sino diriam coisas diferentes sobre o mesmo dia. const doDia = (evs || []).filter(e => e.status !== 'Cancelado'); const ids = doDia.map(e => e.id); setEventos(doDia); if (!ids.length) { setEscalas([]); setFotos([]); setOcorrencias([]); setCarregando(false); return; } const [{ data: es }, { data: fo }, { data: oc }] = await Promise.all([ sb.from('event_escalas').select('id, event_id, status, observacao, check_in_em, check_out_em, freelancers(name)').in('event_id', ids), sb.from('escala_fotos').select('id, event_id').in('event_id', ids), sb.from('escala_ocorrencias').select('id, event_id, categoria, descricao, resolvida_em').in('event_id', ids), ]); setEscalas(es || []); setFotos(fo || []); setOcorrencias(oc || []); setCarregando(false); }, []); // Uma passada na varredura de alertas ao abrir (migration 042). O pg_cron ja // roda de 5 em 5 minutos; esta chamada cobre o projeto onde a extensao nao // esta habilitada — sem ela, la o sino nunca tocaria. Erro aqui nao derruba // o painel: os alertas continuam aparecendo na tela, so nao viram // notificacao, e trocar a tela inteira por isso seria um mau negocio. React.useEffect(() => { sb.rpc('gerar_alertas_operacao').then(({ error }) => { if (error) console.warn('[PainelDoDia.alertas] varredura não rodou:', error.message); }); }, []); React.useEffect(() => { carregar(); const relogio = setInterval(() => setAgora(Date.now()), PDD_MIN); // Sem filtro por evento de proposito: sao varias acoes ao mesmo tempo, e o // realtime aceita um filtro por canal. Recarregar e barato — sao 4 selects // pequenos e so das acoes de hoje. const canal = sb.channel('painel-do-dia') .on('postgres_changes', { event: '*', schema: 'public', table: 'event_escalas' }, carregar) .on('postgres_changes', { event: '*', schema: 'public', table: 'escala_fotos' }, carregar) .on('postgres_changes', { event: '*', schema: 'public', table: 'escala_ocorrencias' }, carregar) .subscribe(); return () => { clearInterval(relogio); sb.removeChannel(canal); }; }, [carregar]); const acoes = React.useMemo( () => eventos .map(ev => resumirAcaoDoDia(ev, escalas, fotos, ocorrencias, agora)) .sort((a, b) => { // Quem precisa de atencao sobe. Depois, ordem de horario. const peso = { atencao: 0, andamento: 1, aguardando: 2, encerrada: 3 }; if (peso[a.estado] !== peso[b.estado]) return peso[a.estado] - peso[b.estado]; return (a.inicio ? a.inicio.getTime() : 0) - (b.inicio ? b.inicio.getTime() : 0); }), [eventos, escalas, fotos, ocorrencias, agora] ); // Dia sem acao nao rende card: o painel some e o Dashboard fica como era. if (carregando || !acoes.length) return null; const totalAlertas = acoes.reduce((s, a) => s + a.alertas.length, 0); return (
Ações de hoje ({acoes.length})
Atualiza sozinho conforme a equipe registra no celular
{totalAlertas > 0 && (
{totalAlertas === 1 ? '1 alerta' : totalAlertas + ' alertas'}
)}
{acoes.map((a, i) => { const est = PDD_ESTADOS[a.estado]; return (
onAbrirAcao && onAbrirAcao(a.evento)} style={{ padding: '14px 24px', cursor: onAbrirAcao ? 'pointer' : 'default', borderBottom: i < acoes.length - 1 ? '1px solid ' + DS.colors.border : 'none', transition: 'background 0.1s' }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{est.emoji} {a.evento.name} {a.evento.client_name && ( · {a.evento.client_name} )} {est.rotulo} {a.evento.event_time || '—'}
{a.encerrados > 0 && } {a.ausentes > 0 && } {a.recusas > 0 && }
{a.alertas.length > 0 && (
{a.alertas.slice(0, 4).map((al, j) => (
{al.texto}
{al.detalhe && (
{al.detalhe}
)}
))} {a.alertas.length > 4 && (
+ {a.alertas.length - 4} alerta(s) — abra a ação para ver
)}
)}
); })}
); } Object.assign(window, { PainelDoDia, resumirAcaoDoDia, pddInicioDoEvento });