first commit

This commit is contained in:
Mateusz Gruszczyński
2026-07-13 13:19:26 +02:00
commit bd562b3da6
50 changed files with 2221 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:var(--bs-border-radius);color:var(--bs-secondary-color)}
+2
View File
@@ -0,0 +1,2 @@
.choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%}
[data-station-proposal-select]+.choices .choices__list--dropdown,[data-station-proposal-select]+.choices .choices__list[aria-expanded]{min-width:100%;width:max-content;max-width:min(42rem,90vw)}
+1
View File
@@ -0,0 +1 @@
.metric strong{display:block;font-size:1.45rem;margin-top:.25rem}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)}
+1
View File
@@ -0,0 +1 @@
html,body{min-height:100%}body{min-height:100vh}main{min-height:calc(100vh - 57px)}.navbar-brand{white-space:nowrap}#live-alerts{max-width:420px}
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.replaceMain=async(url=location.href,{push=false}={})=>{const r=await fetch(url,{headers:{'X-Requested-With':'XMLHttpRequest'}});if(!r.ok)throw new Error('Nie udało się odświeżyć widoku.');const doc=new DOMParser().parseFromString(await r.text(),'text/html');const incoming=F.qs('main',doc),current=F.qs('main');if(!incoming||!current)return;current.replaceWith(incoming);document.documentElement.dataset.bsTheme=doc.documentElement.dataset.bsTheme||'light';const next=F.qs('#bootstrap-theme',doc),theme=F.qs('#bootstrap-theme');if(next&&theme)theme.href=next.href;if(push)history.pushState({},'',url);F.initPage();F.qsa('script:not([src])',doc).forEach(x=>{if(x.textContent.includes('FuelTrack.'))try{Function(x.textContent)()}catch(e){console.error(e)}})};F.submitAjax=async form=>{const b=F.qs('[type=submit]',form),old=b?.innerHTML;if(b){b.disabled=true;b.innerHTML='Zapisywanie…'}try{const r=await fetch(form.action||location.href,{method:(form.dataset.method||form.getAttribute('method')||'POST').toUpperCase(),body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Operacja nie powiodła się.');F.notify(d.message||'Zapisano');bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain((d.data||{}).redirect||location.href,{push:Boolean((d.data||{}).redirect)})}catch(e){F.notify(e.message,'danger')}finally{if(b){b.disabled=false;b.innerHTML=old}}};F.bindAjaxForms=()=>{F.qsa('form.ajax-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();F.submitAjax(f)})});F.qsa('form.ajax-nav-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();F.replaceMain(`${f.action||location.pathname}?${new URLSearchParams(new FormData(f))}`,{push:true}).catch(x=>F.notify(x.message,'danger'))})});F.qsa('a.ajax-nav-link').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',e=>{e.preventDefault();F.replaceMain(a.href,{push:true}).catch(x=>F.notify(x.message,'danger'))})})}})();
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.bindAuth=()=>{const login=F.qs('form.api-login-form');if(login&&!login.dataset.bound){login.dataset.bound='1';login.addEventListener('submit',async e=>{e.preventDefault();try{const r=await fetch('/api/auth/login',{method:'POST',body:new FormData(login),headers:{Accept:'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.message||'Błąd logowania');location.href=(d.data||{}).redirect||'/'}catch(x){F.notify(x.message,'danger')}})}F.qsa('[data-api-logout]').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',async e=>{e.preventDefault();const d=await (await fetch('/api/auth/logout',{method:'POST'})).json();location.href=(d.data||{}).redirect||'/login'})})}})();
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;const text=()=>getComputedStyle(document.body).color;const grid=()=>getComputedStyle(document.documentElement).getPropertyValue('--bs-border-color').trim()||'rgba(0,0,0,.1)';function destroy(id){if(F.charts[id]){F.charts[id].destroy();delete F.charts[id]}}function empty(id,v){F.qs(`#${id}-empty`)?.classList.toggle('d-none',!v);F.qs(`#${id}`)?.classList.toggle('d-none',v)}F.renderBarChart=(id,data)=>{const el=F.qs(`#${id}`);if(!el)return;const labels=Object.keys(data||{});destroy(id);empty(id,!labels.length);if(!labels.length)return;F.charts[id]=new Chart(el,{type:'bar',data:{labels,datasets:[{label:'Cena detaliczna (zł)',data:labels.map(k=>data[k].gross)},{label:'Do zapłaty wg karty (zł)',data:labels.map(k=>data[k].payable)}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:text()}}},scales:{x:{ticks:{color:text()},grid:{color:grid()}},y:{beginAtZero:true,ticks:{color:text()},grid:{color:grid()}}}}})};F.renderMultiLineChart=(id,series,gross=false)=>{const el=F.qs(`#${id}`);if(!el)return;destroy(id);const keys=Object.keys(series||{}).filter(k=>(series[k]||[]).length);empty(id,!keys.length);if(!keys.length)return;const dates=[...new Set(keys.flatMap(k=>series[k].map(p=>p.date)))].sort();F.charts[id]=new Chart(el,{type:'line',data:{labels:dates,datasets:keys.map(k=>{const m=new Map(series[k].map(p=>[p.date,p.value]));return{label:`${k} ${gross?'brutto':'netto'} zł/l`,data:dates.map(d=>m.has(d)?m.get(d):null),spanGaps:true,tension:.2,pointRadius:1.5,borderWidth:2}})},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:text()}}},scales:{x:{ticks:{color:text(),maxTicksLimit:12},grid:{color:grid()}},y:{ticks:{color:text()},grid:{color:grid()}}}}})}})();
+6
View File
@@ -0,0 +1,6 @@
window.FuelTrack=window.FuelTrack||{};
FuelTrack.charts={};
FuelTrack.qs=(s,r=document)=>r.querySelector(s);
FuelTrack.qsa=(s,r=document)=>[...r.querySelectorAll(s)];
FuelTrack.notify=(message,type='success')=>{let host=FuelTrack.qs('#live-alerts');if(!host){host=document.createElement('div');host.id='live-alerts';host.className='position-fixed top-0 end-0 p-3';host.style.zIndex='1080';document.body.append(host)}const el=document.createElement('div');el.className=`alert alert-${type} shadow`;el.textContent=message;host.append(el);setTimeout(()=>el.remove(),3500)};
FuelTrack.debounce=(fn,delay=300)=>{let timer;return(...args)=>{clearTimeout(timer);timer=setTimeout(()=>fn(...args),delay)}};
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.bindFuelPreview=()=>{async function update(){const fuel=F.qs('[name=fuel_type]')?.value,date=F.qs('[name=fueled_at]')?.value,box=F.qs('#orlen-preview');if(!fuel||!date||!box)return;box.textContent='Pobieranie ceny Orlen…';try{const r=await fetch(`/api/orlen/preview?fuel_type=${encodeURIComponent(fuel)}&date=${encodeURIComponent(date)}`),d=await r.json(),x=d.data||d;box.textContent=r.ok&&d.ok?`Orlen: ${Number(x.price_per_liter).toFixed(3)} zł/l (${x.date})`:`Brak ceny: ${d.error||d.message}`}catch{box.textContent='Nie udało się pobrać ceny Orlen.'}}['fuel_type','fueled_at'].forEach(n=>F.qs(`[name=${n}]`)?.addEventListener('change',update));update()}})();
+1
View File
@@ -0,0 +1 @@
FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>{FuelTrack.initSocket();FuelTrack.initPage()});
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.bindLpgRegionPicker=()=>{const select=F.qs('#lpg-region-select');if(!select||select.dataset.choicesBound)return;select.dataset.choicesBound='1';const choices=new Choices(select,{removeItemButton:true,searchEnabled:true,shouldSort:false,position:'bottom',placeholder:true,placeholderValue:'Wybierz województwa',noResultsText:'Brak województwa',noChoicesText:'Brak opcji',itemSelectText:'Wybierz'});const modal=F.qs('#lpgRegionsModal');modal?.addEventListener('hidden.bs.modal',()=>{const count=F.qsa('option:checked',select).length;const badge=F.qs('#lpg-region-count');if(badge)badge.textContent=count?`${count} wybrano`:'Domyślne województwo'})};F.bindOrlenSync=()=>{const btn=F.qs('#orlen-sync-btn');if(!btn||btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',async()=>{const form=F.qs('#orlen-filter-form'),fuels=F.qsa('[name=fuel]:checked',form).map(x=>x.value),year=F.qs('[name=year]',form).value,status=F.qs('#orlen-sync-status');if(!fuels.length){status.textContent='Wybierz co najmniej jedno paliwo.';return}btn.disabled=true;const old=btn.innerHTML;btn.innerHTML='Pobieranie…';try{const regions=F.qsa('[name=region] option:checked',form).map(x=>x.value);const r=await fetch('/api/orlen/sync',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({fuels,year,regions})}),d=await r.json();if(!r.ok)throw new Error(d.error||'Błąd pobierania');status.textContent=d.message;await F.replaceMain(`/orlen?${new URLSearchParams(new FormData(form))}`,{push:true});F.notify(d.message)}catch(e){status.textContent=`Błąd: ${e.message}`;F.notify(e.message,'danger')}finally{btn.disabled=false;btn.innerHTML=old}})}})();
+1
View File
@@ -0,0 +1 @@
FuelTrack.initSocket=()=>{if(typeof io==='undefined'||window._fuelSocket)return;window._fuelSocket=io({transports:['polling','websocket'],upgrade:true,reconnection:true});window._fuelSocket.on('fuel_added',d=>FuelTrack.notify(`Nowe tankowanie: ${d.vehicle}`,'info'))};
+150
View File
@@ -0,0 +1,150 @@
(()=>{
const F=FuelTrack;
let searchController=null,searchSequence=0;
const normalize=value=>String(value??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
const matches=(haystack,query)=>{const source=normalize(haystack),terms=normalize(query).split(/\s+/).filter(Boolean);return terms.every(term=>source.includes(term))};
F.bindStationLiveSearch=()=>{
const form=F.qs('#station-search-form');
if(!form||form.dataset.liveBound)return;
form.dataset.liveBound='1';
const run=F.debounce(async()=>{
const seq=++searchSequence;
searchController?.abort();
searchController=new AbortController();
const p=new URLSearchParams(new FormData(form));p.delete('page');
const url=`${form.action}?${p}`;
try{
const r=await fetch(url,{headers:{'X-Requested-With':'XMLHttpRequest'},signal:searchController.signal});
if(!r.ok)throw new Error('Nie udało się wyszukać stacji.');
const doc=new DOMParser().parseFromString(await r.text(),'text/html');
if(seq!==searchSequence)return;
const incoming=F.qs('main',doc),current=F.qs('main');
if(!incoming||!current)return;
bootstrap.Modal.getInstance(F.qs('#stationPointsModal'))?.hide();
current.replaceWith(incoming);
document.querySelectorAll('.modal-backdrop').forEach(x=>x.remove());
document.body.classList.remove('modal-open');
document.body.style.removeProperty('padding-right');
history.replaceState({},'',url);
F.initPage();
}catch(e){if(e.name!=='AbortError')F.notify(e.message,'danger')}
},300);
F.qs('[data-live-search]',form)?.addEventListener('input',run);
F.qsa('[data-live-sort]',form).forEach(x=>x.addEventListener('change',run));
};
F.bindFavoriteStations=()=>{
F.qsa('.favorite-station-form').forEach(form=>{
if(form.dataset.favoriteSearchBound)return;
form.dataset.favoriteSearchBound='1';
const input=F.qs('.station-modal-search',form),tbody=F.qs('tbody',form);
if(!input||!tbody)return;
let empty=F.qs('.favorite-search-empty',tbody);
if(!empty){
empty=document.createElement('tr');empty.className='favorite-search-empty d-none';
empty.innerHTML='<td colspan="3" class="text-center text-body-secondary py-4">Brak pasujących stacji.</td>';
tbody.append(empty);
}
const filter=()=>{
const rows=F.qsa('tr[data-station-name]',tbody);
let visible=0;
rows.forEach(row=>{
const searchable=row.dataset.stationName||row.textContent;
const show=matches(searchable,input.value);
row.hidden=!show;
row.classList.toggle('d-none',!show);
if(show)visible++;
});
empty.hidden=visible!==0;
empty.classList.toggle('d-none',visible!==0);
};
input.addEventListener('input',filter);
input.addEventListener('search',filter);
const modal=form.closest('.modal');
modal?.addEventListener('shown.bs.modal',()=>{input.focus();filter()});
modal?.addEventListener('hidden.bs.modal',()=>{input.value='';filter()});
filter();
});
};
F.bindStationFavoriteToggles=()=>{
F.qsa('.station-favorite-toggle').forEach(button=>{
if(button.dataset.bound)return;
button.dataset.bound='1';
button.addEventListener('click',async()=>{
const original=button.textContent;
button.disabled=true;
button.textContent='Zapisywanie…';
try{
const r=await fetch(button.dataset.url,{method:'POST',headers:{Accept:'application/json','X-Requested-With':'XMLHttpRequest'}});
const d=await r.json();
if(!r.ok||!d.ok)throw new Error(d.message||d.error||'Nie udało się zmienić ulubionych.');
const favorite=Boolean(d.data&&d.data.favorite);
button.textContent=favorite?'Usuń z ulubionych':'Dodaj do ulubionych';
F.notify(d.message||'Zapisano');
}catch(e){button.textContent=original;F.notify(e.message,'danger')}
finally{button.disabled=false}
});
});
};
// Zachowany jako no-op: formularz tankowania używa natywnego selecta z optgroup.
F.bindStationProposalSelect=()=>{};
F.bindStationPoints=()=>{
const modalEl=F.qs('#stationPointsModal');
if(!modalEl)return;
const search=F.qs('#stationPointsSearch',modalEl);
if(search&&!search.dataset.bound){
search.dataset.bound='1';
search.addEventListener('input',F.debounce(()=>F.loadStationPoints?.(),250));
}
};
F.loadStationPoints=async()=>{
const modalEl=F.qs('#stationPointsModal');
if(!modalEl||!modalEl.dataset.stationId)return;
const rows=F.qs('#stationPointsRows',modalEl),empty=F.qs('#stationPointsEmpty',modalEl),loading=F.qs('#stationPointsLoading',modalEl),count=F.qs('#stationPointsCount',modalEl),search=F.qs('#stationPointsSearch',modalEl);
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
loading.classList.remove('d-none');empty.classList.add('d-none');rows.innerHTML='';
try{
const r=await fetch(`/api/stations/${modalEl.dataset.stationId}/points?q=${encodeURIComponent(search.value.trim())}&per_page=100`,{headers:{Accept:'application/json','X-Requested-With':'XMLHttpRequest'}}),d=await r.json();
if(!r.ok||!d.ok)throw new Error(d.message||'Nie udało się pobrać punktów');
count.textContent=`Liczba punktów: ${d.data.total}`;
rows.innerHTML=d.data.items.map(x=>`<tr><td><strong>${esc(x.name)}</strong>${x.dkn?`<div class="small text-body-secondary">DKN: ${esc(x.dkn)}</div>`:''}</td><td>${esc(x.address)}</td><td>${esc(x.region||'—')}</td><td>${x.has_petrol?'<span class="badge text-bg-secondary me-1">benzyna</span>':''}${x.has_diesel?'<span class="badge text-bg-secondary me-1">diesel</span>':''}${x.has_lpg?'<span class="badge text-bg-secondary">LPG</span>':''}</td></tr>`).join('');
empty.classList.toggle('d-none',d.data.items.length>0);
}catch(e){empty.textContent=e.message;empty.classList.remove('d-none')}
finally{loading.classList.add('d-none')}
};
if(!document.documentElement.dataset.stationPointsDelegated){
document.documentElement.dataset.stationPointsDelegated='1';
document.addEventListener('click',e=>{
const b=e.target.closest('.station-points-open');
if(!b)return;
const modalEl=F.qs('#stationPointsModal');
if(!modalEl)return;
modalEl.dataset.stationId=b.dataset.stationId;
F.qs('#stationPointsTitle',modalEl).textContent=`Punkty: ${b.dataset.stationName}`;
F.qs('#stationPointsSearch',modalEl).value='';
F.qs('#stationPointsCount',modalEl).textContent='';
bootstrap.Modal.getOrCreateInstance(modalEl).show();
F.loadStationPoints();
});
}
F.bindStationInlineCompany=()=>{
F.qsa('.station-inline-company-select').forEach(select=>{
if(select.dataset.bound)return;
select.dataset.bound='1';
select.addEventListener('change',()=>{
const url=new URL(location.href);
url.searchParams.set('company_id',select.value);
url.searchParams.delete('page');
F.replaceMain(url.toString(),{push:true}).catch(e=>F.notify(e.message,'danger'));
});
});
};
})();
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.bindUserModal=()=>{const form=F.qs('#user-edit-form'),deleteBtn=F.qs('#user-delete-btn');F.qsa('.user-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{form.action=`/api/users/${btn.dataset.id}`;form.dataset.method='PUT';form.dataset.userId=btn.dataset.id;['name','email','role'].forEach(k=>form.elements[k].value=btn.dataset[k]);if(form.elements.fuel_card_id)form.elements.fuel_card_id.value=btn.dataset.card||'';if(form.elements.company_id)form.elements.company_id.value=btn.dataset.company||'';form.elements.password.value='';form.elements.active.checked=btn.dataset.active==='1'})});if(deleteBtn&&!deleteBtn.dataset.bound){deleteBtn.dataset.bound='1';deleteBtn.addEventListener('click',async()=>{const id=form?.dataset.userId;if(!id)return;if(!confirm('Usunąć tego użytkownika? Tej operacji nie można cofnąć.'))return;const old=deleteBtn.innerHTML;deleteBtn.disabled=true;deleteBtn.innerHTML='Usuwanie…';try{const r=await fetch(`/api/users/${id}`,{method:'DELETE',headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Nie udało się usunąć użytkownika.');F.notify(d.message||'Usunięto użytkownika');bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{deleteBtn.disabled=false;deleteBtn.innerHTML=old}})}}})();