RotaGo

Acessar o RotaGo

Entre com o e-mail e a senha cadastrados para sincronizar seus dados.

Conectando ao serviço de autenticação…
RotaGo
Gestão inteligente de viagens

Painel

Resumo da sua operação

Viagens recentes

Resumo financeiro

Nova viagem

Preencha os dados e confira o cálculo antes de salvar.

Rota e viagem

Com Google Maps configurado, sugestões aparecem durante a digitação.
Arraste a ordem usando as setas. Selecione sugestões do Google Maps quando disponíveis.
Selecione uma sugestão para identificar o endereço.
Informe origem e destino. A integração com Maps pode calcular distância e duração automaticamente.

Valores e custos

A sugestão de pedágio aparecerá após calcular a rota, se disponível.
Tarifa calculada
R$ 0,00
Aguardando distância e duração.
Custos registrados: R$ 0,00

Viagens

Consulte, edite e atualize pagamentos.

Clientes

Cadastro e desempenho por cliente.

Cadastro de cliente

Lista de clientes

Motoristas e veículos

Cadastre quantos precisar e escolha os principais para novas viagens.

Motoristas

Desenhe com o dedo ou mouse. Salve o motorista para guardar a assinatura.

Veículos

Relatórios

Rentabilidade, custos e desempenho.

Despesas por categoria

Desempenho por cliente

Tarifas e configurações

Parâmetros utilizados nos cálculos futuros.

Regra de tarifa por quilômetro

Fórmula: tarifa-base × [1 + (minutos/km − referência) × percentual]. O acréscimo é limitado a zero quando a viagem é mais rápida que a referência.

Google Maps Platform

A chave de API usada no navegador pode ser vista por usuários técnicos. Restrinja-a por domínio (HTTP referrers) no Google Cloud e habilite somente as APIs necessárias. Não use uma chave irrestrita.
Requer faturamento e APIs habilitadas no Google Cloud. A chave fica salva neste navegador. Para uso em produção, o ideal é um backend/proxy para operações sensíveis.

Backup e restauração

Proteja seus clientes, viagens e configurações.

Exportar backup

Gera um arquivo JSON com todos os dados armazenados neste navegador.

Restaurar backup

A restauração substitui os dados atuais. O arquivo será validado antes da confirmação.

Estado dos dados

`; popup.document.open();popup.document.write(html);popup.document.close();popup.focus(); } function quickClient(){const name=prompt('Nome ou empresa do cliente:');if(!name?.trim())return;const c={id:crypto.randomUUID(),name:name.trim(),phone:'',notes:'',createdAt:new Date().toISOString()};db.clients.push(c);persist();refreshClientSelect();document.getElementById('tClient').value=c.id;toast('Cliente cadastrado.')} function saveClient(e){e.preventDefault();const id=val('cId');const previous=id?db.clients.find(x=>x.id===id)||{}:{};const c={...previous,id:id||crypto.randomUUID(),name:val('cName').trim(),phone:val('cPhone').trim(),notes:val('cNotes').trim(),document:val('cDocument').trim(),whatsapp:val('cWhatsapp').trim(),email:val('cEmail').trim(),cep:val('cCep').trim(),address:val('cAddress').trim()};if(id)db.clients[db.clients.findIndex(x=>x.id===id)]=c;else db.clients.push(c);persist();resetClient();toast('Cliente salvo.')} function resetClient(){document.getElementById('clientForm').reset();document.getElementById('cId').value=''}function editClient(id){const c=db.clients.find(x=>x.id===id);if(!c)return;document.getElementById('cId').value=c.id;document.getElementById('cName').value=c.name;document.getElementById('cPhone').value=c.phone||'';document.getElementById('cNotes').value=c.notes||'';for(const [field,key] of [['cDocument','document'],['cWhatsapp','whatsapp'],['cEmail','email'],['cCep','cep'],['cAddress','address']])document.getElementById(field).value=c[key]||''} function deleteClient(id){if(db.trips.some(t=>t.clientId===id)){toast('Este cliente possui viagens. Preserve o histórico ou edite as viagens antes de excluir.');return}if(confirm('Excluir cliente?')){db.clients=db.clients.filter(c=>c.id!==id);persist()}} function renderClients(){const el=document.getElementById('clientsList');if(!db.clients.length){el.innerHTML='
Nenhum cliente cadastrado.
';return}el.innerHTML=db.clients.map(c=>{const ts=db.trips.filter(t=>t.clientId===c.id),sum=ts.reduce((a,t)=>a+t.charged,0),rec=ts.reduce((a,t)=>a+(Number(t.received)||0),0),km=ts.reduce((a,t)=>a+(Number(t.km)||0),0),average=km>0?money(sum/km)+'/km':'—';return `
${esc(c.name)}${ts.length} viagens
${esc(c.phone||'Sem telefone')} · Cobrado ${money(sum)} · Recebido ${money(rec)} · Média cobrada ${average}
${c.notes?`
${esc(c.notes)}
`:''}
`}).join('')} function clientHistory(id){showPage('trips');document.getElementById('filterClient').value=id;renderTrips()} function sum(a,k){return a.reduce((s,x)=>s+(Number(x[k])||0),0)} function renderDashboard(){const ts=db.trips,received=sum(ts,'received'),charged=sum(ts,'charged'),km=sum(ts,'km'),cost=sum(ts,'fuel')+sum(ts,'other')+sum(ts,'tolls');const stats=[['Total recebido',money(received),'green'],['Total de viagens',ts.length,''],['Quilômetros percorridos',km.toLocaleString('pt-BR',{maximumFractionDigits:1})+' km',''],['Recebido por km',money(km?received/km:0)+'/km',''],['Despesas registradas',money(cost),'red'],['A receber',money(Math.max(0,charged-received)),'']];document.getElementById('dashboardStats').innerHTML=stats.map(s=>`
${s[0]}
${s[1]}
`).join('');const recent=ts.slice(0,6);document.getElementById('recentTrips').innerHTML=recent.length?recent.map(t=>`
${esc(clientName(t.clientId))}
${dateBR(t.date)} · ${esc(t.destination||'Destino não informado')}
${money(t.charged)}
${t.km} km
`).join(''):'
Suas viagens aparecerão aqui.
';document.getElementById('financeSummary').innerHTML=`
Total cobrado${money(charged)}
Total recebido${money(received)}
Custos registrados${money(cost+sum(ts,'extras')+sum(ts,'stops')*0)}
Resultado após custos${money(received-cost)}
Resultado simplificado: recebido menos combustível, pedágios e outros custos.
`} function reportTrips(){const a=val('rStart'),b=val('rEnd');return db.trips.filter(t=>(!a||t.date>=a)&&(!b||t.date<=b))}function renderReports(){const ts=reportTrips(),charged=sum(ts,'charged'),received=sum(ts,'received'),km=sum(ts,'km'),fuel=sum(ts,'fuel'),tolls=sum(ts,'tolls'),extras=sum(ts,'extras'),other=sum(ts,'other'),stopCost=sum(ts,'stops')*0,expenses=fuel+tolls+other,profit=received-expenses;const arr=[['Recebido',money(received),'green'],['Viagens',ts.length,''],['Distância',km.toFixed(1)+' km',''],['Média recebida/km',money(km?received/km:0),''],['Combustível',money(fuel),'red'],['Pedágios',money(tolls),'red'],['Extras cobrados',money(extras),''],['Outros custos',money(other),'red'],['Resultado financeiro',money(profit),profit>=0?'green':'red'],['Pendente',money(charged-received),'']];document.getElementById('reportStats').innerHTML=arr.map(s=>`
${s[0]}
${s[1]}
`).join('');document.getElementById('expenseBreakdown').innerHTML=[['Combustível',fuel],['Pedágios',tolls],['Outros custos',other]].map(x=>`
${x[0]}${money(x[1])}
`).join('')+`
Total${money(expenses)}
`;const cp=db.clients.map(c=>{const xs=ts.filter(t=>t.clientId===c.id);return {name:c.name,n:xs.length,received:sum(xs,'received'),km:sum(xs,'km')}}).filter(x=>x.n).sort((a,b)=>b.received-a.received);document.getElementById('clientPerformance').innerHTML=cp.length?cp.map(c=>`
${esc(c.name)}
${c.n} viagens · ${c.km.toFixed(1)} km
${money(c.received)}
`).join(''):'
Sem dados para o período.
'} function fillSettings(){const s=db.settings;document.getElementById('sBase').value=s.baseRate;document.getElementById('sMinutes').value=s.normalMinutesPerKm;document.getElementById('sIncrease').value=s.increasePct;document.getElementById('sMinimum').value=s.minimumFare;document.getElementById('mapsKey').value=s.mapsKey||'';document.getElementById('mapsEnabled').checked=!!s.mapsEnabled;document.getElementById('mapsStatus').textContent=s.mapsEnabled?'Integração habilitada.':'Integração desativada.'} function saveSettings(e){e.preventDefault();db.settings.baseRate=num('sBase');db.settings.normalMinutesPerKm=num('sMinutes');db.settings.increasePct=num('sIncrease');db.settings.minimumFare=num('sMinimum');persist();toast('Parâmetros de tarifa atualizados.')} function saveMaps(e){e.preventDefault();db.settings.mapsKey=val('mapsKey').trim();db.settings.mapsEnabled=document.getElementById('mapsEnabled').checked;persist();toast('Configuração do Maps salva.');if(db.settings.mapsEnabled)loadGoogleMaps()} function testMaps(){db.settings.mapsKey=val('mapsKey').trim();db.settings.mapsEnabled=true;persist();loadGoogleMaps()} function loadGoogleMaps(){ const status=document.getElementById('mapsStatus'); if(!db.settings.mapsKey){status.textContent='Informe uma chave de API.';return} if(mapsLoaded){status.textContent='Places API (New) já carregada.';attachAutocomplete();return} if(document.getElementById('rotagoMapsScript'))return; const script=document.createElement('script');script.id='rotagoMapsScript'; script.src='https://maps.googleapis.com/maps/api/js?key='+encodeURIComponent(db.settings.mapsKey)+'&v=weekly&loading=async&callback=onGoogleMapsReady'; script.async=true;script.onerror=()=>{script.remove();status.textContent='Falha ao carregar Google Maps. Confira a chave e o faturamento.'}; document.head.appendChild(script);status.textContent='Carregando Places API (New)…'; } window.onGoogleMapsReady=async function(){ try{await google.maps.importLibrary('places');mapsLoaded=true;await attachAutocomplete();document.querySelectorAll('#stopAddressList .stop-address-row').forEach(attachStopAutocomplete);document.getElementById('mapsStatus').textContent='Places API (New) carregada. Teste os endereços em Nova viagem.'} catch(err){console.error('Erro Places API New:',err);document.getElementById('mapsStatus').textContent='Falha na Places API (New): '+(err.message||err)} }; async function attachAutocomplete(){ const {PlaceAutocompleteElement}=await google.maps.importLibrary('places'); for(const [id,hint,which] of [['tOrigin','originHint','origin'],['tDestination','destHint','destination']]){ const original=document.getElementById(id); if(original.dataset.autocomplete==='new')continue; const widget=new PlaceAutocompleteElement(); widget.setAttribute('placeholder','Rua, número, cidade'); widget.setAttribute('aria-label',which==='origin'?'Endereço de origem':'Endereço de destino'); widget.style.cssText='display:block;width:100%;min-height:46px;'; original.insertAdjacentElement('afterend',widget); original.style.display='none';original.dataset.autocomplete='new'; if(original.value)widget.value=original.value; widget.addEventListener('gmp-select',async event=>{ try{ const prediction=event.placePrediction; if(!prediction)return; const place=prediction.toPlace(); await place.fetchFields({fields:['formattedAddress','location','displayName']}); const address=place.formattedAddress||place.displayName||''; original.value=address;widget.value=address; if(which==='origin')originPlace=place;else destinationPlace=place; document.getElementById(hint).textContent='Endereço identificado pela Places API (New).'; original.dispatchEvent(new Event('change',{bubbles:true})); await tryRoute(); }catch(err){console.error('Erro ao selecionar endereço:',err);document.getElementById(hint).textContent='Erro ao identificar endereço: '+(err.message||err)} }); widget.addEventListener('input',()=>{original.value=widget.value||'';if(which==='origin')originPlace=null;else destinationPlace=null;invalidateRoute();}); document.getElementById(hint).textContent='Digite e selecione uma sugestão da Places API (New).'; } } function invalidateRoute(){ routeRequestId++; const status=document.getElementById('routeNotice'); status.textContent='Percurso alterado. Clique em “Calcular / recalcular rota” para atualizar quilômetros, duração e tarifa. Os valores anteriores podem estar desatualizados.'; status.className='notice warn'; document.getElementById('tollHint').textContent='Pedágios: calcule novamente a rota para consultar uma nova estimativa.'; } async function tryRoute(){ const status=document.getElementById('routeNotice'); const origin=val('tOrigin').trim(),destination=val('tDestination').trim(); const rows=[...document.querySelectorAll('#stopAddressList .stop-address-row')]; if(!origin||!destination){status.textContent='Informe origem e destino antes de calcular a rota.';status.className='notice warn';return} if(rows.some(r=>!r.querySelector('.stop-address').value.trim())){status.textContent='Preencha ou exclua as paradas vazias antes de calcular.';status.className='notice warn';return} if(!db.settings.mapsEnabled||!db.settings.mapsKey){status.textContent='Ative e configure o Google Maps em Tarifas e configurações para calcular a rota.';status.className='notice warn';return} const request=++routeRequestId; status.className='notice';status.textContent='Calculando percurso completo e consultando pedágios…'; try{ const waypoint=(address,place)=>place?.location?{location:{latLng:{latitude:place.location.lat(),longitude:place.location.lng()}}}:{address}; const body={origin:waypoint(origin,originPlace),destination:waypoint(destination,destinationPlace),intermediates:rows.map(r=>waypoint(r.querySelector('.stop-address').value.trim(),r._place)),travelMode:'DRIVE',routingPreference:'TRAFFIC_UNAWARE',languageCode:'pt-BR',units:'METRIC',extraComputations:['TOLLS'],routeModifiers:{vehicleInfo:{emissionType:'GASOLINE'},tollPasses:[]}}; const response=await fetch('https://routes.googleapis.com/directions/v2:computeRoutes',{method:'POST',headers:{'Content-Type':'application/json','X-Goog-Api-Key':db.settings.mapsKey,'X-Goog-FieldMask':'routes.distanceMeters,routes.duration,routes.travelAdvisory.tollInfo'},body:JSON.stringify(body)}); const data=await response.json();if(!response.ok)throw Error(data.error?.message||'HTTP '+response.status); const route=data.routes?.[0];if(!route)throw Error('Nenhuma rota encontrada');if(request!==routeRequestId)return; const km=route.distanceMeters/1000,minutes=Math.ceil(parseFloat(route.duration||'0')/60); document.getElementById('tKm').value=km.toFixed(2);document.getElementById('tMinutes').value=minutes;rateManual=false; const toll=route.travelAdvisory?.tollInfo;const prices=toll?.estimatedPrice||[];const brl=prices.find(x=>x.currencyCode==='BRL');const hint=document.getElementById('tollHint'); if(brl){const amount=Number(brl.units||0)+Number(brl.nanos||0)/1e9;hint.innerHTML='Pedágio estimado: '+money(amount)+' · Você pode alterar o valor manualmente.'} else hint.textContent=toll?'Há indicação de pedágio, mas sem preço estimado em reais. Informe manualmente.':'Não há estimativa de pedágio disponível para esta rota. Isso não garante ausência de pedágios.'; status.className='notice good';status.textContent='Rota calculada: '+km.toFixed(2)+' km · '+minutes+' min · '+rows.length+' parada(s). Você pode ajustar distância, duração e pedágios manualmente.';updateFare(); }catch(err){if(request!==routeRequestId)return;console.error('Erro Routes API:',err);status.className='notice warn';status.textContent='Não foi possível calcular a rota: '+(err.message||err)+'. Verifique se a Routes API está habilitada e autorizada para este domínio. Distância, duração e pedágios podem ser informados manualmente.'} } function applySuggestedToll(amount){document.getElementById('tTolls').value=Number(amount).toFixed(2);updateFare();toast('Sugestão de pedágio aplicada. Você pode editar o valor.')} function syncMapWidgets(){syncStopWidgets();for(const id of ['tOrigin','tDestination']){const input=document.getElementById(id);const widget=input.nextElementSibling;if(widget?.tagName==='GMP-PLACE-AUTOCOMPLETE')widget.value=input.value||''}} function exportBackup(){const payload={...clone(db),exportedAt:new Date().toISOString(),app:'RotaGo'};const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='rotago-backup-'+today()+'.json';a.click();URL.revokeObjectURL(url);toast('Backup exportado.')} function importBackup(){const f=document.getElementById('backupFile').files[0],status=document.getElementById('backupStatus');if(!f){status.textContent='Selecione um arquivo JSON.';return}const reader=new FileReader();reader.onload=()=>{try{const x=JSON.parse(reader.result);if(!x||!Array.isArray(x.clients)||!Array.isArray(x.trips)||!x.settings||typeof x.settings!=='object')throw Error('Estrutura incompatível.');if(!x.trips.every(t=>t.id&&typeof t.km==='number'&&typeof t.charged==='number'))throw Error('Há viagens com campos obrigatórios inválidos.');if(!x.clients.every(c=>c.id&&c.name))throw Error('Há clientes inválidos.');if(!confirm(`Backup válido: ${x.clients.length} clientes e ${x.trips.length} viagens. Substituir TODOS os dados atuais? Recomenda-se exportar um backup antes.`))return;db={...clone(defaults),...x,settings:{...defaults.settings,...x.settings}};persist();status.textContent='Restauração concluída.';toast('Backup restaurado com sucesso.')}catch(err){status.textContent='Arquivo não restaurado: '+err.message}};reader.readAsText(f)} function renderAll(){refreshClientSelect();renderDashboard();renderTrips();renderClients();renderFleet();refreshFleetSelect();renderReports();document.getElementById('dataStatus').innerHTML=`
Clientes cadastrados${db.clients.length}
Viagens registradas${db.trips.length}
Última gravação nesta sessão${new Date().toLocaleString('pt-BR')}
Dados sincronizados com sua conta RotaGo na nuvem. Faça backups periódicos como proteção adicional.
`} function init(){document.getElementById('tDate').value=today();document.getElementById('tBase').value=db.settings.baseRate;document.getElementById('tRate').value=db.settings.baseRate;renderAll();if(db.settings.mapsEnabled&&db.settings.mapsKey)loadGoogleMaps();updateFare()} init();