// Mapa da Equipe — orcamento 310, item 19. // // O QUE O ORCAMENTO PEDE // "Adicionar mapa dentro da acao. Mostrar o local da acao e os registros de // check-in realizados. Nao e necessario acompanhar a localizacao do // profissional continuamente." // // A SEGUNDA FRASE E A PARTE IMPORTANTE // Rastreio continuo seria mais facil de vender e pior de usar: derruba a // bateria do celular de quem esta trabalhando e transforma a ferramenta em // vigilancia. O que o mapa mostra sao os pontos que a operacao ja gravava por // conta propria — chegada, foto, ocorrencia, saida. Cada ponto tem um motivo // para existir, e entre um e outro ninguem e seguido. // // POR QUE circleMarker E NAO O PIN PADRAO // O marcador padrao do Leaflet e uma imagem PNG resolvida por caminho // relativo. Servido de CDN, ele quebra calado e o mapa aparece sem pino // nenhum. circleMarker e SVG puro: nao depende de arquivo nenhum. const MAPA_LEAFLET_JS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; const MAPA_LEAFLET_CSS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; // Carregado sob demanda, e nao no index.html, porque o mapa vive numa aba que // a maioria das telas nunca abre — nao ha por que atrasar o login de todo mundo // por causa dele. let mapaLeafletPromise = null; function carregarLeaflet() { if (window.L) return Promise.resolve(window.L); if (mapaLeafletPromise) return mapaLeafletPromise; mapaLeafletPromise = new Promise((resolve, reject) => { const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = MAPA_LEAFLET_CSS; document.head.appendChild(css); const js = document.createElement('script'); js.src = MAPA_LEAFLET_JS; js.onload = () => resolve(window.L); js.onerror = () => { mapaLeafletPromise = null; reject(new Error('Não consegui carregar o mapa.')); }; document.head.appendChild(js); }); return mapaLeafletPromise; } const MAPA_TIPOS = { local: { cor: '#F97316', rotulo: 'Local da ação' }, check_in: { cor: '#16A34A', rotulo: 'Check-in' }, check_out: { cor: '#2563EB', rotulo: 'Check-out' }, foto: { cor: '#8B5CF6', rotulo: 'Foto' }, ocorrencia: { cor: '#DC2626', rotulo: 'Ocorrência' }, }; const mapaNum = v => (v === null || v === undefined || v === '' ? null : Number(v)); const mapaHora = iso => iso ? new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : null; // Escapa o que vai para dentro do popup: nome de pessoa e descricao de // ocorrencia sao texto digitado, e o popup do Leaflet aceita HTML. function mapaEsc(t) { return String(t === null || t === undefined ? '' : t) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } // Monta a lista de pontos a partir do que a operacao ja gravou. function pontosDaAcao(event, escalas, fotos, ocorrencias) { const pontos = []; const evLat = mapaNum(event && event.local_lat); const evLng = mapaNum(event && event.local_lng); if (evLat !== null && evLng !== null) { pontos.push({ tipo: 'local', lat: evLat, lng: evLng, titulo: event.name || 'Local da ação', linhas: [event.location || null], raio: mapaNum(event.raio_checkin_metros), }); } (escalas || []).forEach(e => { const nome = (e.freelancers && e.freelancers.name) || 'Profissional'; const ciLat = mapaNum(e.check_in_lat), ciLng = mapaNum(e.check_in_lng); if (ciLat !== null && ciLng !== null) { pontos.push({ tipo: 'check_in', lat: ciLat, lng: ciLng, titulo: nome, linhas: [ mapaHora(e.check_in_em) ? 'Chegou às ' + mapaHora(e.check_in_em) : null, e.check_in_distancia_m !== null && e.check_in_distancia_m !== undefined ? Math.round(e.check_in_distancia_m) + ' m do local' : null, e.check_in_endereco || null, ], }); } const coLat = mapaNum(e.check_out_lat), coLng = mapaNum(e.check_out_lng); if (coLat !== null && coLng !== null) { pontos.push({ tipo: 'check_out', lat: coLat, lng: coLng, titulo: nome, linhas: [ mapaHora(e.check_out_em) ? 'Saiu às ' + mapaHora(e.check_out_em) : null, e.check_out_endereco || null, ], }); } }); (fotos || []).forEach(f => { const lat = mapaNum(f.lat), lng = mapaNum(f.lng); if (lat === null || lng === null) return; pontos.push({ tipo: 'foto', lat, lng, titulo: f.categoria || 'Foto', // escala_fotos marca a hora em enviada_em; ocorrencia usa criada_em. linhas: [mapaHora(f.enviada_em), f.legenda || null, f.endereco || null], }); }); (ocorrencias || []).forEach(o => { const lat = mapaNum(o.lat), lng = mapaNum(o.lng); if (lat === null || lng === null) return; pontos.push({ tipo: 'ocorrencia', lat, lng, titulo: o.categoria || 'Ocorrência', linhas: [mapaHora(o.criada_em), o.descricao || null, o.resolvida_em ? 'Resolvida' : 'Em aberto'], }); }); return pontos; } function MapaLegenda({ presentes }) { return (
{Object.keys(MAPA_TIPOS).filter(t => presentes.has(t)).map(t => ( {MAPA_TIPOS[t].rotulo} ))}
); } function MapaDaAcao({ event }) { const [escalas, setEscalas] = React.useState([]); const [fotos, setFotos] = React.useState([]); const [ocorrencias, setOcorrencias] = React.useState([]); const [carregando, setCarregando] = React.useState(true); const [erro, setErro] = React.useState(null); // O local da acao vem do banco, nao da prop. Quem abre o modal pela Agenda // passa um evento montado para a lista, sem local_lat/local_lng — e o mapa // aparecia sem o ponto do local e sem o circulo do raio, silenciosamente. const [acao, setAcao] = React.useState(null); const divRef = React.useRef(null); const mapaRef = React.useRef(null); React.useEffect(() => { if (!event || !event.id) return; let vivo = true; (async () => { const [{ data: ev }, { data: es, error: e1 }, { data: fo }, { data: oc }] = await Promise.all([ sb.from('events').select('id, name, location, local_lat, local_lng, raio_checkin_metros') .eq('id', event.id).maybeSingle(), sb.from('event_escalas') .select('id, check_in_em, check_in_lat, check_in_lng, check_in_endereco, check_in_distancia_m, check_out_em, check_out_lat, check_out_lng, check_out_endereco, freelancers(name)') .eq('event_id', event.id), sb.from('escala_fotos').select('id, lat, lng, endereco, categoria, legenda, enviada_em').eq('event_id', event.id), sb.from('escala_ocorrencias').select('id, lat, lng, categoria, descricao, criada_em, resolvida_em').eq('event_id', event.id), ]); if (!vivo) return; if (e1) { console.error('[MapaDaAcao] Supabase error:', e1); setErro('Não consegui carregar os registros da ação.'); } setAcao(ev || event); setEscalas(es || []); setFotos(fo || []); setOcorrencias(oc || []); setCarregando(false); })(); return () => { vivo = false; }; }, [event && event.id]); const pontos = React.useMemo( () => pontosDaAcao(acao, escalas, fotos, ocorrencias), [acao, escalas, fotos, ocorrencias] ); React.useEffect(() => { if (carregando || !pontos.length || !divRef.current) return; let vivo = true; carregarLeaflet().then(L => { if (!vivo || !divRef.current) return; // Trocar de aba desmonta e remonta: sem destruir o anterior, o Leaflet // reclama que o container ja esta inicializado e o mapa some. if (mapaRef.current) { mapaRef.current.remove(); mapaRef.current = null; } const mapa = L.map(divRef.current, { scrollWheelZoom: false }); mapaRef.current = mapa; L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, // Exigencia da licenca do OpenStreetMap, nao enfeite. attribution: '© OpenStreetMap', }).addTo(mapa); pontos.forEach(p => { const cor = MAPA_TIPOS[p.tipo].cor; // O raio do check-in desenhado junto responde de olho a pergunta que a // producao faz: a pessoa deu check-in de dentro ou de fora do local? if (p.tipo === 'local' && p.raio) { L.circle([p.lat, p.lng], { radius: p.raio, color: cor, weight: 1, fillColor: cor, fillOpacity: 0.08 }).addTo(mapa); } const linhas = (p.linhas || []).filter(Boolean).map(mapaEsc).join('
'); L.circleMarker([p.lat, p.lng], { radius: p.tipo === 'local' ? 9 : 7, color: '#fff', weight: 2, fillColor: cor, fillOpacity: 1, }).addTo(mapa).bindPopup( '' + mapaEsc(p.titulo) + '
' + '' + MAPA_TIPOS[p.tipo].rotulo + '' + (linhas ? '
' + linhas : '') ); }); const limites = L.latLngBounds(pontos.map(p => [p.lat, p.lng])); mapa.fitBounds(limites, { padding: [30, 30], maxZoom: 17 }); // O container nasce dentro de um modal que ainda esta abrindo; sem isto o // Leaflet mede altura zero e pinta so um quadrado cinza. setTimeout(() => { if (vivo && mapaRef.current) mapaRef.current.invalidateSize(); }, 120); }).catch(e => { if (vivo) setErro(e.message); }); return () => { vivo = false; if (mapaRef.current) { mapaRef.current.remove(); mapaRef.current = null; } }; }, [carregando, pontos]); if (carregando) return ; if (erro) { return (
{erro}
); } // Sem ponto nenhum o mapa seria um retangulo do centro do oceano. O texto // explica o que falta em vez de deixar a producao achando que quebrou. if (!pontos.length) { return (
Nada para mostrar no mapa ainda
Cadastre o local da ação na aba Escala, ou espere os primeiros check-ins.
A localização só é gravada no check-in, no envio de registros, na ocorrência e no check-out.
); } const presentes = new Set(pontos.map(p => p.tipo)); return (
{pontos.length} {pontos.length === 1 ? 'ponto registrado' : 'pontos registrados'} · a equipe não é acompanhada continuamente: a posição só é gravada nos momentos acima.
); } Object.assign(window, { MapaDaAcao, pontosDaAcao });