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
+99
View File
@@ -0,0 +1,99 @@
from flask import Flask
import click
from sqlalchemy import text
from .extensions import db, login_manager, socketio
from .models import User, CompanySettings, AppSetting
from .config import Config
THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")}
def _themes(app):
return {
"bootstrap": {"label":"Bootstrap", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"light"},
"flatly": {"label":"Flatly", "url":app.config["THEME_FLATLY_URL"], "mode":"light"},
"darkly": {"label":"Darkly", "url":app.config["THEME_DARKLY_URL"], "mode":"dark"},
"pulse": {"label":"Pulse", "url":app.config["THEME_PULSE_URL"], "mode":"light"},
}
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_object(Config)
if test_config: app.config.update(test_config)
db.init_app(app); login_manager.init_app(app); socketio.init_app(app)
from .auth import auth; from .main import main
from .api import api
from .openapi import docs
app.register_blueprint(auth); app.register_blueprint(main); app.register_blueprint(api); app.register_blueprint(docs)
@login_manager.user_loader
def load_user(user_id): return db.session.get(User, int(user_id))
@app.context_processor
def inject_theme():
themes = _themes(app)
record = db.session.get(AppSetting, "theme")
theme_name = record.value if record and record.value in themes else "bootstrap"
return {"app_theme": themes[theme_name], "app_theme_name": theme_name, "themes": themes,
"asset_urls": {"bootstrap_js": app.config["BOOTSTRAP_JS_URL"], "choices_css": app.config["CHOICES_CSS_URL"],
"choices_js": app.config["CHOICES_JS_URL"], "chart_js": app.config["CHART_JS_URL"], "socket_io_js": app.config["SOCKET_IO_JS_URL"]}}
@app.get("/health")
def health(): return {"status":"ok"}
@app.cli.command("reset-admin-password")
@click.option("--email", required=True, help="Adres e-mail konta administratora.")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True,
help="Nowe hasło. Bez tej opcji polecenie poprosi o hasło bez wyświetlania go.")
def reset_admin_password(email, password):
"""Resetuje hasło istniejącego administratora."""
normalized_email = email.strip().lower()
user = User.query.filter_by(email=normalized_email).first()
if not user:
raise click.ClickException("Nie znaleziono użytkownika o podanym adresie e-mail.")
if user.role != "admin":
raise click.ClickException("Wskazany użytkownik nie ma roli administratora.")
if len(password) < 8:
raise click.ClickException("Hasło musi mieć co najmniej 8 znaków.")
user.set_password(password)
user.active = True
db.session.commit()
click.echo(f"Zresetowano hasło administratora: {normalized_email}")
with app.app_context():
db.create_all()
if db.engine.dialect.name == "sqlite":
def add_column(table, column, ddl, index_sql=None):
columns = {row[1] for row in db.session.execute(text(f"PRAGMA table_info({table})")).fetchall()}
if column not in columns:
db.session.execute(text(f"ALTER TABLE {table} ADD COLUMN {ddl}"))
if index_sql:
db.session.execute(text(index_sql))
add_column("fuel_entry", "station_company_id", "station_company_id INTEGER REFERENCES fuel_station_company(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_station_company_id ON fuel_entry (station_company_id)")
add_column("fuel_entry", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_fuel_card_id ON fuel_entry (fuel_card_id)")
add_column("vehicle", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_fuel_card_id ON vehicle (fuel_card_id)")
add_column("user", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_user_fuel_card_id ON user (fuel_card_id)")
add_column("user", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_user_company_id ON user (company_id)")
add_column("vehicle", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_company_id ON vehicle (company_id)")
add_column("fuel_card", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_card_company_id ON fuel_card (company_id)")
add_column("company_settings", "active", "active BOOLEAN NOT NULL DEFAULT 1")
add_column("fuel_station_company", "brand_name", "brand_name VARCHAR(120) NOT NULL DEFAULT ''", "CREATE INDEX IF NOT EXISTS ix_fuel_station_company_brand_name ON fuel_station_company (brand_name)")
db.session.commit()
if not CompanySettings.query.first(): db.session.add(CompanySettings())
if not db.session.get(AppSetting, "theme"): db.session.add(AppSetting(key="theme", value="bootstrap"))
if not db.session.get(AppSetting, "invoice_split_enabled"): db.session.add(AppSetting(key="invoice_split_enabled", value="1"))
if not User.query.first():
u=User(email="admin@example.com", name="Administrator", role="admin"); u.set_password("admin123!"); db.session.add(u)
db.session.commit()
from .models import FuelStationCompany
from .integrations.ure import infer_brand
for station in FuelStationCompany.query.filter(db.or_(FuelStationCompany.brand_name.is_(None), FuelStationCompany.brand_name == "")).all():
station.brand_name = infer_brand(station.company_name)
db.session.commit()
default_company=CompanySettings.query.order_by(CompanySettings.id).first()
if default_company:
User.query.filter(User.company_id.is_(None)).update({User.company_id:default_company.id})
db.session.execute(text("UPDATE vehicle SET company_id = (SELECT company_id FROM user WHERE user.id = vehicle.owner_id) WHERE company_id IS NULL"))
db.session.execute(text("UPDATE fuel_card SET company_id = :cid WHERE company_id IS NULL"), {"cid":default_company.id})
db.session.commit()
return app
+414
View File
@@ -0,0 +1,414 @@
from collections import defaultdict
from datetime import datetime, date
from decimal import Decimal
from functools import wraps
from flask import Blueprint, jsonify, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from .extensions import db, socketio
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
from .station_catalog import sync_station_catalog
from . import THEMES
api = Blueprint('api', __name__, url_prefix='/api')
FUEL_TYPES = ('PB95','PB98','DIESEL','LPG')
def response(data=None, message=None, status=200):
payload={'ok': status < 400}
if message: payload['message']=message
if data is not None: payload['data']=data
return jsonify(payload), status
def fail(message, status=400): return response(message=message,status=status)
def role_required(*allowed):
def deco(fn):
@wraps(fn)
@login_required
def wrapped(*a,**kw):
if current_user.role not in allowed: return fail('Brak uprawnień',403)
return fn(*a,**kw)
return wrapped
return deco
def payload():
return request.get_json(silent=True) or request.form
def boolv(data,key):
v=data.get(key)
return v in (True,1,'1','true','on','yes')
def accessible_vehicles():
if current_user.role=='admin': return Vehicle.query.order_by(Vehicle.name).all()
ids={v.id for v in current_user.owned_vehicles+current_user.shared_vehicles}
return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else []
def user_dict(u): return {'id':u.id,'name':u.name,'email':u.email,'role':u.role,'active':u.active,'fuel_card_id':u.fuel_card_id,'fuel_card':u.fuel_card.name if u.fuel_card else None,'company_id':u.company_id,'company':u.company.name if u.company else None}
def vehicle_dict(v): return {'id':v.id,'name':v.name,'make':v.make,'model':v.model,'registration':v.registration,'fuel_type':v.fuel_type,'owner':user_dict(v.owner),'drivers':[user_dict(x) for x in v.drivers],'has_fuel_card':bool(v.fuel_card_id),'fuel_card_id':v.fuel_card_id,'fuel_card':v.fuel_card.name if v.fuel_card else None,'current_odometer':v.current_odometer}
def station_dict(s):
settings=CompanySettings.query.first()
return {'id':s.id,'brand_name':s.display_name,'company_name':s.company_name,'nip':s.nip,'regon':s.regon,'brands':s.station_name_list,'regions':s.region_list,'selected_region':s.selected_region,'station_count':s.station_count,'has_petrol':s.has_petrol,'has_diesel':s.has_diesel,'has_lpg':s.has_lpg,'use_orlen_last_price':s.use_orlen_last_price,'active':s.active,'favorite':s in current_user.favorite_stations if current_user.is_authenticated else False,'allowed':s in settings.allowed_stations if settings else True}
@api.post('/auth/login')
def login():
d=payload(); user=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401)
login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano')
@api.post('/auth/logout')
@login_required
def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano')
@api.get('/me')
@login_required
def me(): return response(user_dict(current_user))
@api.get('/vehicles')
@login_required
def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()])
@api.post('/vehicles')
@login_required
def create_vehicle():
d=payload()
try:
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id)
if not owner: return fail('Nie znaleziono właściciela',404)
if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403)
if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403)
v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0))
db.session.add(v); db.session.commit(); socketio.emit('app_changed',{'resource':'vehicle','id':v.id}); return response(vehicle_dict(v),'Dodano pojazd',201)
except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400)
@api.put('/vehicles/<int:vehicle_id>/fuel-card')
@login_required
def assign_vehicle_fuel_card(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
card=db.session.get(FuelCard,card_id) if card_id else None
if card_id and not card:return fail('Nie znaleziono karty paliwowej',404)
if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400)
v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();socketio.emit('app_changed',{'resource':'vehicle','id':v.id});return response(vehicle_dict(v),'Zapisano kartę pojazdu')
@api.post('/vehicles/<int:vehicle_id>/shares')
@login_required
def share_vehicle(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403)
d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not u:return fail('Nie znaleziono użytkownika',404)
if u not in v.drivers:v.drivers.append(u);db.session.commit()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Udostępniono pojazd')
@api.delete('/vehicles/<int:vehicle_id>/shares/<int:user_id>')
@login_required
def unshare_vehicle(vehicle_id,user_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
u=User.query.get_or_404(user_id)
if u in v.drivers:v.drivers.remove(u);db.session.commit()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Odebrano dostęp')
@api.put('/vehicles/<int:vehicle_id>/card-policy')
@login_required
def card_policy(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v)
p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None
db.session.add(p);db.session.commit();socketio.emit('app_changed',{'resource':'card_policy','id':v.id});return response(message='Zapisano zasady rozliczeń')
@api.post('/fuel-entries')
@login_required
def create_fuel_entry():
d=payload(); vehicles=accessible_vehicles()
try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None)
except Exception: vehicle=None
if not vehicle:return fail('Brak dostępu do pojazdu',403)
try:
odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None
settings=vehicle.company or current_user.company or CompanySettings.query.first()
if station_company and settings.allowed_stations and station_company not in settings.allowed_stations:
return fail('Ta stacja nie jest dozwolona przez politykę firmy',403)
wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region
try:
p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source']
except Exception: pass
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id)
card=db.session.get(FuelCard,card_id) if card_id else None
used_card=boolv(d,'used_fuel_card') and bool(card)
if used_card and station_company:
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first()
if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403)
e=FuelEntry(vehicle_id=vehicle.id,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source)
vehicle.current_odometer=odo;db.session.add(e);db.session.commit();socketio.emit('fuel_added',{'vehicle':vehicle.name,'gross':round(e.gross,2)});return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201)
except Exception as exc:db.session.rollback();return fail(str(exc),400)
@api.get('/orlen/prices')
@login_required
def orlen_prices():
year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS]
rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all()
return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions])
@api.post('/orlen/sync')
@role_required('boss','admin')
def orlen_sync():
d=payload(); fuels=d.get('fuels',[]); fuels=[fuels] if isinstance(fuels,str) else fuels; fuels=[f.upper() for f in fuels if f.upper() in FUEL_TYPES]; year=int(d.get('year',date.today().year))
regions=d.get('regions',[]); regions=[regions] if isinstance(regions,str) else regions; regions=[str(r).strip().lower() for r in regions if str(r).strip().lower() in POLISH_REGIONS]
if not fuels:return fail('Wybierz co najmniej jedno paliwo')
settings=CompanySettings.query.first(); result={}; total=0
try:
for fuel in fuels:
added=updated=0
for row in fetch_orlen_range(fuel,date(year,1,1),min(date(year,12,31),date.today()),'all' if fuel=='LPG' else settings.region):
if fuel=='LPG' and regions and row.get('region') not in regions: continue
x=OrlenPrice.query.filter_by(fuel_type=row['fuel_type'],effective_date=row['effective_date'],region=row['region']).first()
if x:
for k,v in row.items():setattr(x,k,v)
x.fetched_at=datetime.utcnow();updated+=1
else:db.session.add(OrlenPrice(**row));added+=1
result[fuel]={'added':added,'updated':updated};total+=added+updated
db.session.commit();socketio.emit('app_changed',{'resource':'orlen'});return response(result,f'Przetworzono {total} rekordów')
except Exception as exc:db.session.rollback();return fail(str(exc),502)
@api.get('/orlen/preview')
@login_required
def orlen_preview():
try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region))
except Exception as exc:return fail(str(exc),502)
@api.get('/users')
@role_required('admin')
def users():
page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query
if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%')))
p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total})
@api.post('/users')
@role_required('boss','admin')
def create_user():
d=payload()
try:
if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków')
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/users/<int:user_id>')
@role_required('admin')
def update_user(user_id):
u=User.query.get_or_404(user_id);d=payload()
try:
u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','')
if pw:
if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków')
u.set_password(pw)
db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika')
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.delete('/users/<int:user_id>')
@role_required('admin')
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
return fail('Nie możesz usunąć własnego konta administratora', 400)
if user.role == 'admin':
remaining_active_admins = User.query.filter(
User.role == 'admin', User.active.is_(True), User.id != user.id
).count()
if remaining_active_admins < 1:
return fail('Nie można usunąć ostatniego aktywnego administratora', 409)
if user.owned_vehicles:
return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409)
if FuelEntry.query.filter_by(user_id=user.id).first():
return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409)
user.shared_vehicles.clear()
user.favorite_stations.clear()
db.session.delete(user)
db.session.commit()
socketio.emit('app_changed', {'resource':'users','deleted_id':user_id})
return response(message='Usunięto użytkownika')
@api.get('/settings')
@role_required('boss','admin')
def get_settings():
s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')})
@api.put('/settings')
@role_required('boss','admin')
def update_settings():
d=payload();s=CompanySettings.query.first()
try:
s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15)
db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();socketio.emit('app_changed',{'resource':'settings'});return response(message='Zapisano ustawienia')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.get('/app-settings')
@role_required('boss','admin')
def get_app_settings():
return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value})
@api.put('/app-settings')
@role_required('boss','admin')
def update_app_settings():
d=payload();theme=d.get('theme','bootstrap')
if theme not in THEMES:return fail('Nieprawidłowy motyw')
db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();socketio.emit('app_changed',{'resource':'app_settings'});return response(message='Zapisano ustawienia aplikacji')
@api.get('/fuel-cards')
@login_required
def get_fuel_cards():
rows=FuelCard.query.order_by(FuelCard.name).all()
return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows])
@api.post('/fuel-cards')
@role_required('boss','admin')
def create_fuel_card():
d=payload()
try:
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id)
db.session.add(card);db.session.commit();socketio.emit('app_changed',{'resource':'fuel_cards'});return response({'id':card.id},'Dodano kartę paliwową',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/fuel-cards/<int:card_id>')
@role_required('boss','admin')
def update_fuel_card(card_id):
card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę')
@api.put('/fuel-cards/<int:card_id>/stations/<int:station_id>')
@role_required('boss','admin')
def update_card_station_rule(card_id,station_id):
FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload()
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id)
rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0)
db.session.add(rule);db.session.commit();socketio.emit('app_changed',{'resource':'card_station_rule'});return response(message='Zapisano warunki karty dla stacji')
@api.get('/stations')
@login_required
def stations():
q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True)
if q:
for term in [x for x in q.split() if x]:
needle=f'%{term}%'
query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle)))
sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc')
allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region}
col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc())
p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction})
@api.get('/stations/<int:station_id>/points')
@login_required
def station_points(station_id):
station=FuelStationCompany.query.get_or_404(station_id)
page=max(request.args.get('page',1,type=int),1)
per_page=min(max(request.args.get('per_page',50,type=int),1),100)
q=(request.args.get('q') or '').strip()
query=FuelStationPoint.query.filter_by(station_company_id=station.id)
if q:
needle=f'%{q}%'
query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle)))
query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc())
result=query.paginate(page=page,per_page=per_page,error_out=False)
items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items]
return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total})
@api.put('/me/favorite-stations')
@login_required
def favorite_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji')
current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'favorite_stations','user_id':current_user.id});return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje')
@api.put('/settings/allowed-stations')
@role_required('boss','admin')
def allowed_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'allowed_stations'});return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje')
@api.post('/stations/sync')
@role_required('boss','admin')
def stations_sync():
try:
companies=aggregate_ure_companies(fetch_ure_stations())
added,updated=sync_station_catalog(companies)
db.session.commit();socketio.emit('app_changed',{'resource':'stations'});return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}')
except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502)
@api.put('/stations/<int:station_id>')
@role_required('boss','admin')
def update_station(station_id):
s=FuelStationCompany.query.get_or_404(station_id);d=payload();region=(d.get('selected_region') or '').strip().lower()
if region and region not in s.region_list:return fail('Wybrane województwo nie występuje w danych tej firmy')
try:
company_id=int(d.get('company_id') or current_user.company_id or 0)
except (TypeError,ValueError):
return fail('Nieprawidłowa firma')
settings=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=settings.id:return fail('Brak uprawnień',403)
s.selected_region=region;s.active=boolv(d,'active');allowed=boolv(d,'allowed')
if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s)
if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s)
db.session.commit();socketio.emit('app_changed',{'resource':'stations','company_id':settings.id});return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}')
@api.get('/companies')
@role_required('boss','admin')
def companies_list():
return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()])
@api.post('/companies')
@role_required('admin')
def companies_create():
d=payload()
try:
c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True)
if not c.name: raise ValueError('Nazwa firmy jest wymagana')
db.session.add(c);db.session.commit();return response({'id':c.id,'redirect':url_for('main.admin_companies',company_id=c.id)},'Dodano firmę',201)
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.put('/companies/<int:company_id>')
@role_required('boss','admin')
def companies_update(company_id):
c=CompanySettings.query.get_or_404(company_id);d=payload()
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
try:
c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active')
db.session.commit();socketio.emit('app_changed',{'resource':'company','id':c.id});return response(message='Zapisano firmę')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.put('/companies/<int:company_id>/favorite-stations')
@role_required('boss','admin')
def company_favorites(company_id):
c=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception:return fail('Nieprawidłowa lista stacji')
if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response(message='Zapisano ulubione stacje firmy')
@api.post('/companies/<int:company_id>/favorite-stations/<int:station_id>/toggle')
@role_required('boss','admin')
def company_favorite_toggle(company_id,station_id):
c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
if s in c.favorite_stations:c.favorite_stations.remove(s);state=False
else:
if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations.append(s);state=True
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response({'favorite':state},'Zmieniono ulubione stacje')
+19
View File
@@ -0,0 +1,19 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required
from .models import User
auth = Blueprint("auth", __name__)
@auth.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = User.query.filter_by(email=request.form["email"].strip().lower()).first()
if user and user.check_password(request.form["password"]):
login_user(user); return redirect(url_for("main.dashboard"))
flash("Nieprawidłowy e-mail lub hasło", "danger")
return render_template("login.html")
@auth.route("/logout")
@login_required
def logout():
logout_user(); return redirect(url_for("auth.login"))
+73
View File
@@ -0,0 +1,73 @@
import json
import os
def env(name, default=None):
value = os.getenv(name)
return default if value is None or value == "" else value
def env_float(name, default):
try:
return float(env(name, default))
except (TypeError, ValueError):
return float(default)
def env_int(name, default):
try:
return int(env(name, default))
except (TypeError, ValueError):
return int(default)
def env_json(name, default):
raw = env(name)
if raw is None:
return default
try:
return json.loads(raw)
except json.JSONDecodeError:
return default
class Config:
SECRET_KEY = env("SECRET_KEY", "dev-secret")
SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:///fueltrack.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30)
ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15)
URE_TIMEOUT_SECONDS = env_float("URE_TIMEOUT_SECONDS", 45)
ORLEN_WHOLESALE_URL = env("ORLEN_WHOLESALE_URL", "https://tool.orlen.pl/api/wholesalefuelprices/ByProduct")
ORLEN_LPG_URL = env("ORLEN_LPG_URL", "https://tool.orlen.pl/api/autogasprices")
ORLEN_LPG_BY_DATE_URL = env("ORLEN_LPG_BY_DATE_URL", "https://tool.orlen.pl/api/autogasprices/ByDate")
URE_STATIONS_URL = env("URE_STATIONS_URL", "https://api.ure.gov.pl/api/InfrastructureFuelStation")
BOOTSTRAP_JS_URL = env("BOOTSTRAP_JS_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js")
CHOICES_CSS_URL = env("CHOICES_CSS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css")
CHOICES_JS_URL = env("CHOICES_JS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js")
CHART_JS_URL = env("CHART_JS_URL", "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js")
SOCKET_IO_JS_URL = env("SOCKET_IO_JS_URL", "https://cdn.socket.io/4.8.1/socket.io.min.js")
THEME_BOOTSTRAP_URL = env("THEME_BOOTSTRAP_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css")
THEME_FLATLY_URL = env("THEME_FLATLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css")
THEME_DARKLY_URL = env("THEME_DARKLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css")
THEME_PULSE_URL = env("THEME_PULSE_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/pulse/bootstrap.min.css")
STATION_BRAND_ALIASES = env_json("STATION_BRAND_ALIASES_JSON", {
"ORLEN": "ORLEN",
"HIL-GAZ": "HIL-GAZ",
"HILL-GAZ": "HIL-GAZ",
"BP ": "BP",
"BP EUROPA": "BP",
"SHELL": "Shell",
"MOL POLSKA": "MOL",
"CIRCLE K": "Circle K",
"ANWIM": "MOYA",
"AMIC": "AMIC Energy",
"LOTOS": "LOTOS",
"AVIA": "AVIA",
"PIEPRZYK": "Pieprzyk",
})
+14
View File
@@ -0,0 +1,14 @@
from decimal import Decimal, ROUND_HALF_UP
def money(value):
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def calculate_costs(gross, vat_rate=23, vat_deduction_percent=50):
gross = Decimal(str(gross)); rate = Decimal(str(vat_rate))
deduction = Decimal(str(vat_deduction_percent)) / Decimal("100")
net = gross / (Decimal("1") + rate / Decimal("100")); vat = gross - net
deductible_vat = vat * deduction
return {"gross": money(gross), "net": money(net), "vat": money(vat),
"deductible_vat": money(deductible_vat), "final_cost": money(gross - deductible_vat)}
+2
View File
@@ -0,0 +1,2 @@
def invoice_period(dt, split_day=15):
return f"{dt:%Y-%m}-01{split_day:02d}" if dt.day <= split_day else f"{dt:%Y-%m}-{split_day + 1:02d}koniec"
+8
View File
@@ -0,0 +1,8 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_socketio import SocketIO
db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = "auth.login"
socketio = SocketIO(async_mode="threading", cors_allowed_origins="*", ping_timeout=60, ping_interval=25)
+9
View File
@@ -0,0 +1,9 @@
import requests
from flask import current_app
def get_json(url, params=None, timeout_key="HTTP_TIMEOUT_SECONDS"):
timeout = float(current_app.config.get(timeout_key, current_app.config["HTTP_TIMEOUT_SECONDS"]))
response = requests.get(url, params=params, timeout=timeout)
response.raise_for_status()
return response.json()
+70
View File
@@ -0,0 +1,70 @@
from datetime import date, timedelta
from decimal import Decimal
from flask import current_app
from .http import get_json
PRODUCT_IDS = {"PB95": 41, "PB98": 42, "DIESEL": 43}
POLISH_REGIONS = (
"dolnośląskie", "kujawsko-pomorskie", "lubelskie", "lubuskie", "łódzkie",
"małopolskie", "mazowieckie", "opolskie", "podkarpackie", "podlaskie",
"pomorskie", "śląskie", "świętokrzyskie", "warmińsko-mazurskie",
"wielkopolskie", "zachodnio-pomorskie",
)
def _rows(url, params=None):
data = get_json(url, params=params, timeout_key="ORLEN_TIMEOUT_SECONDS")
if not isinstance(data, list):
raise ValueError("Nieprawidłowa odpowiedź API Orlen")
return data
def fetch_orlen_range(fuel_type, date_from, date_to, region="mazowieckie"):
fuel_type = fuel_type.upper()
if fuel_type == "LPG":
result, seen = [], set()
current = date_from
while current <= date_to:
rows = _rows(current_app.config["ORLEN_LPG_BY_DATE_URL"], {"date": current.isoformat()})
for row in rows:
row_region = (row.get("locationName") or "").strip().lower()
if region and str(region).lower() not in ("all", "*") and row_region != str(region).lower():
continue
effective = date.fromisoformat(row["effectiveDate"][:10])
if not (date_from <= effective <= date_to):
continue
key = (effective, row_region)
if key in seen:
continue
seen.add(key)
value = Decimal(str(row["value"]))
result.append({"fuel_type": "LPG", "effective_date": effective, "price_per_liter": value,
"raw_value": value, "region": row_region, "product_name": row.get("productName") or "LPG",
"source": f"Orlen LPG ByDate, {row_region}"})
current += timedelta(days=1)
return result
product_id = PRODUCT_IDS.get(fuel_type)
if not product_id:
raise ValueError("Nieobsługiwany rodzaj paliwa")
rows = _rows(current_app.config["ORLEN_WHOLESALE_URL"], {
"productId": product_id, "from": date_from.isoformat(), "to": date_to.isoformat()
})
result = []
for row in rows:
effective = date.fromisoformat(row["effectiveDate"][:10])
raw = Decimal(str(row["value"]))
result.append({"fuel_type": fuel_type, "effective_date": effective, "price_per_liter": raw / Decimal("1000"),
"raw_value": raw, "region": "", "product_name": row.get("productName") or fuel_type,
"source": f"Orlen {row.get('productName') or fuel_type}, netto/m³ → zł/l"})
return result
def fetch_orlen_price(fuel_type, day=None, region="mazowieckie"):
day = day or date.today()
rows = fetch_orlen_range(fuel_type, day.replace(day=1), day, region)
candidates = [row for row in rows if row["effective_date"] <= day]
if not candidates:
raise ValueError("Brak ceny Orlen dla wybranej daty")
row = max(candidates, key=lambda item: item["effective_date"])
return {"price_per_liter": float(row["price_per_liter"]), "date": row["effective_date"].isoformat(), "source": row["source"]}
+112
View File
@@ -0,0 +1,112 @@
import hashlib
import re
from flask import current_app
from .http import get_json
LEGAL_SUFFIXES = re.compile(r"\b(SPÓŁKA|SPÓŁKA AKCYJNA|SP\. ?Z ?O\. ?O\.|S\. ?A\.|ODDZIAŁ|SPÓŁKA EUROPEJSKA)\b.*$", re.I)
def fetch_ure_stations():
payload = get_json(current_app.config["URE_STATIONS_URL"], timeout_key="URE_TIMEOUT_SECONDS")
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("items", "results", "data", "value"):
if isinstance(payload.get(key), list):
return payload[key]
raise ValueError("Nieprawidłowa odpowiedź API URE")
def _clean(value):
return " ".join(str(value or "").strip().split())
def infer_brand(company_name, station_name=""):
combined = f"{_clean(company_name)} {_clean(station_name)}".upper()
aliases = current_app.config.get("STATION_BRAND_ALIASES", {})
# Najpierw szukamy marki w nazwie konkretnej stacji, później w przedsiębiorcy.
for token, label in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True):
if token.upper() in combined:
return label
normalized = _clean(company_name)
shortened = LEGAL_SUFFIXES.sub("", normalized).strip(" ,-\"")
return shortened or normalized or "Nieznana sieć"
def _address_parts(row):
return [
_clean(row.get("infraUlica")),
_clean(row.get("infraNrLokalu")),
_clean(row.get("infraKod")) or _clean(row.get("kod")),
_clean(row.get("infraPoczta")) or _clean(row.get("miejscowosc")) or _clean(row.get("poczta")),
_clean(row.get("wojewodztwo")).lower(),
]
def _point_key(row, index):
address = "|".join(x.casefold() for x in _address_parts(row) if x)
station_name = _clean(row.get("nazwaStacji")).casefold()
raw = address or f"{station_name}|{index}"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
dkn = _clean(row.get("dkn"))
storage_key = f"{dkn or 'no-dkn'}:{digest}"
return raw, storage_key
def _merge_point(target, source):
for field in ("station_name", "street", "street_number", "postal_code", "city", "region", "coordinates"):
if not target.get(field) and source.get(field):
target[field] = source[field]
for field in ("has_petrol", "has_diesel", "has_lpg"):
target[field] = bool(target.get(field) or source.get(field))
def aggregate_ure_companies(rows):
grouped = {}
aliases = {label.casefold() for label in current_app.config.get("STATION_BRAND_ALIASES", {}).values()}
for index, row in enumerate(rows, start=1):
nip, regon = _clean(row.get("nip")), _clean(row.get("regon"))
company_name = _clean(row.get("nazwa")) or "Nieznana firma"
station_name = _clean(row.get("nazwaStacji"))
brand = infer_brand(company_name, station_name)
# Rozpoznane sieci agregujemy jako jedną markę, niezależnie od operatora/franczyzobiorcy.
if brand.casefold() in aliases:
key = f"brand:{brand.casefold()}"
else:
key = f"nip:{nip}" if nip else (f"regon:{regon}" if regon else f"name:{company_name.casefold()}")
item = grouped.setdefault(key, {
"ure_key": key,
"brand_name": brand,
"company_name": brand if key.startswith("brand:") else company_name,
"nip": None if key.startswith("brand:") else (nip or None),
"regon": None if key.startswith("brand:") else (regon or None),
"station_names": set(), "regions": set(),
"has_petrol": False, "has_diesel": False, "has_lpg": False,
"points": {},
})
if station_name: item["station_names"].add(station_name)
region = _clean(row.get("wojewodztwo")).lower()
if region: item["regions"].add(region)
item["has_petrol"] |= bool(row.get("benzynySilnikowe"))
item["has_diesel"] |= bool(row.get("olejeNapedowe"))
item["has_lpg"] |= bool(row.get("gazPlynnyLPG"))
point_key, stored_dkn = _point_key(row, index)
point = {
"ure_dkn": stored_dkn,
"station_name": station_name or brand,
"street": _clean(row.get("infraUlica")), "street_number": _clean(row.get("infraNrLokalu")),
"postal_code": _clean(row.get("infraKod")) or _clean(row.get("kod")),
"city": _clean(row.get("infraPoczta")) or _clean(row.get("miejscowosc")) or _clean(row.get("poczta")),
"region": region, "coordinates": _clean(row.get("wspolrzedne")),
"has_petrol": bool(row.get("benzynySilnikowe")), "has_diesel": bool(row.get("olejeNapedowe")), "has_lpg": bool(row.get("gazPlynnyLPG")),
}
existing = item["points"].get(point_key)
_merge_point(existing, point) if existing else item["points"].__setitem__(point_key, point)
result = []
for item in grouped.values():
item["points"] = list(item["points"].values())
item["station_count"] = len(item["points"])
item["station_names"] = "|".join(sorted(item["station_names"], key=str.casefold))
item["regions"] = "|".join(sorted(item["regions"], key=str.casefold))
result.append(item)
return sorted(result, key=lambda x: (x["brand_name"].casefold(), x["company_name"].casefold()))
+393
View File
@@ -0,0 +1,393 @@
from collections import defaultdict
from datetime import datetime, date
from decimal import Decimal
from functools import wraps
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from .extensions import db, socketio
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
from .station_catalog import sync_station_catalog
from . import THEMES
main = Blueprint("main", __name__)
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
def wants_json():
return request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json
def ok_response(message, **extra):
socketio.emit("app_changed", {"message": message})
if wants_json():
return jsonify({"ok": True, "message": message, **extra})
flash(message, "success")
return None
def error_response(message, status=400):
if wants_json():
return jsonify({"ok": False, "error": message}), status
flash(message, "danger")
return None
def roles(*allowed):
def deco(fn):
@wraps(fn)
def wrapped(*a, **kw):
if current_user.role not in allowed:
flash("Brak uprawnień", "danger")
return redirect(url_for("main.dashboard"))
return fn(*a, **kw)
return wrapped
return deco
def accessible_vehicles():
if current_user.role == "admin":
return Vehicle.query.order_by(Vehicle.name).all()
if current_user.role == "boss" and current_user.company_id:
return Vehicle.query.filter_by(company_id=current_user.company_id).order_by(Vehicle.name).all()
ids = {v.id for v in current_user.owned_vehicles + current_user.shared_vehicles}
return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else []
@main.route("/")
@login_required
def dashboard():
settings = CompanySettings.query.first(); vehicles = accessible_vehicles(); ids = [v.id for v in vehicles]
split_enabled = (db.session.get(AppSetting, "invoice_split_enabled") or AppSetting(key="invoice_split_enabled", value="1")).value == "1"
month = request.args.get("month", datetime.now().strftime("%Y-%m"))
try: year, mon = map(int, month.split("-"))
except ValueError: year, mon = datetime.now().year, datetime.now().month; month=f"{year:04d}-{mon:02d}"
entries = FuelEntry.query.filter(FuelEntry.vehicle_id.in_(ids), extract("year", FuelEntry.fueled_at)==year, extract("month", FuelEntry.fueled_at)==mon).order_by(FuelEntry.fueled_at.desc()).all() if ids else []
totals = {"gross": Decimal("0"), "deductible_vat": Decimal("0"), "final_cost": Decimal("0"), "liters": Decimal("0")}
by_vehicle = defaultdict(lambda: {"gross": 0, "payable": 0, "liters": 0}); invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {}
for e in entries:
c = calculate_costs(e.gross, settings.vat_rate, settings.vat_deduction_percent)
totals["gross"] += c["gross"]; totals["deductible_vat"] += c["deductible_vat"]; totals["final_cost"] += c["final_cost"]; totals["liters"] += Decimal(e.liters)
station_policy = e.station_company
normal_price = float(e.price_per_liter)
payable_price = normal_price
rule = FuelCardStationRule.query.filter_by(fuel_card_id=e.fuel_card_id, station_company_id=e.station_company_id).first() if e.used_fuel_card and e.fuel_card_id and e.station_company_id else None
uses_station_last_price = bool(rule and rule.use_orlen_last_price and e.wholesale_price is not None)
if uses_station_last_price:
payable_price = float(e.wholesale_price) * (1 + float(settings.vat_rate) / 100)
if rule:
net = payable_price / (1 + float(settings.vat_rate) / 100)
net = net * (1 - float(rule.discount_net_percent or 0) / 100) + float(rule.surcharge_net_per_liter or 0)
payable_price = net * (1 + float(settings.vat_rate) / 100)
payable_price = max(payable_price, 0)
payable_gross = float(e.liters) * payable_price
settlements[e.id] = {"normal_price": normal_price, "payable_price": payable_price, "normal_gross": e.gross, "payable_gross": payable_gross, "uses_last_price": uses_station_last_price}
by_vehicle[e.vehicle.name]["gross"] += float(c["gross"]); by_vehicle[e.vehicle.name]["payable"] += payable_gross; by_vehicle[e.vehicle.name]["liters"] += float(e.liters)
if e.used_fuel_card:
key = invoice_period(e.fueled_at, settings.invoice_split_day) if split_enabled else "Cały miesiąc"
else:
key = "Poza kartą"
invoices[key]["gross"] += float(c["gross"]); invoices[key]["payable"] += payable_gross; invoices[key]["count"] += 1
return render_template("dashboard.html", vehicles=vehicles, entries=entries, totals=totals, month=month, by_vehicle=dict(by_vehicle), invoices=dict(invoices), settings=settings, split_enabled=split_enabled, settlements=settlements)
@main.route("/vehicles", methods=["GET", "POST"])
@login_required
def vehicles():
if request.method == "POST":
v = Vehicle(name=request.form["name"], make=request.form["make"], model=request.form["model"], registration=request.form["registration"].upper(), fuel_type=request.form["fuel_type"], owner_id=current_user.id, has_fuel_card="has_fuel_card" in request.form, card_provider=request.form.get("card_provider"), current_odometer=int(request.form.get("current_odometer") or 0))
db.session.add(v); db.session.commit(); response = ok_response("Dodano pojazd", vehicle_id=v.id); return response or redirect(url_for("main.vehicles"))
company_id=current_user.company_id
users_q=User.query.filter_by(active=True)
cards_q=FuelCard.query.filter_by(active=True)
if current_user.role=='boss' and company_id:
users_q=users_q.filter_by(company_id=company_id);cards_q=cards_q.filter_by(company_id=company_id)
return render_template("vehicles.html", vehicles=accessible_vehicles(), fuel_cards=cards_q.order_by(FuelCard.name).all(), assignable_users=users_q.order_by(User.name).all())
@main.route("/vehicles/<int:vehicle_id>/share", methods=["POST"])
@login_required
def share_vehicle(vehicle_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
response = error_response("Tylko właściciel lub admin może udostępniać auto", 403); return response or redirect(url_for("main.vehicles"))
user = User.query.filter_by(email=request.form["email"].strip().lower()).first()
if not user:
response = error_response("Nie znaleziono użytkownika", 404); return response or redirect(url_for("main.vehicles"))
if user not in vehicle.drivers: vehicle.drivers.append(user); db.session.commit()
response = ok_response("Udostępniono pojazd"); return response or redirect(url_for("main.vehicles"))
@main.post("/vehicles/<int:vehicle_id>/unshare/<int:user_id>")
@login_required
def unshare_vehicle(vehicle_id, user_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
return error_response("Tylko właściciel lub admin może odebrać dostęp", 403)
user = User.query.get_or_404(user_id)
if user in vehicle.drivers:
vehicle.drivers.remove(user); db.session.commit()
response = ok_response("Odebrano dostęp do pojazdu")
return response or redirect(url_for("main.vehicles"))
@main.post("/vehicles/<int:vehicle_id>/card-policy")
@login_required
def update_card_policy(vehicle_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
return error_response("Tylko właściciel lub admin może zmienić zasady rozliczeń", 403)
policy = vehicle.fuel_card_policy or FuelCardPolicy(vehicle=vehicle)
policy.use_orlen_last_price = "use_orlen_last_price" in request.form
policy.discount_percent = Decimal(request.form.get("discount_percent") or 0)
policy.surcharge_per_liter = Decimal(request.form.get("surcharge_per_liter") or 0)
policy.description = request.form.get("description", "").strip() or None
db.session.add(policy); db.session.commit()
response = ok_response("Zapisano zasady rozliczeń karty")
return response or redirect(url_for("main.vehicles"))
@main.route("/fuel", methods=["GET", "POST"])
@login_required
def fuel():
vehicles = accessible_vehicles()
if request.method == "POST":
vehicle = next((v for v in vehicles if v.id == int(request.form["vehicle_id"])), None)
if not vehicle:
response = error_response("Brak dostępu do pojazdu", 403); return response or redirect(url_for("main.fuel"))
odometer = int(request.form["odometer"])
if odometer < vehicle.current_odometer:
response = error_response("Licznik nie może być niższy od poprzedniego"); return response or redirect(url_for("main.fuel"))
fueled_at = datetime.fromisoformat(request.form["fueled_at"]); wholesale = None; source = None
station_company_id = request.form.get("station_company_id", type=int)
station_company = db.session.get(FuelStationCompany, station_company_id) if station_company_id else None
price_region = station_company.selected_region if station_company and station_company.selected_region else CompanySettings.query.first().region
try:
p = fetch_orlen_price(request.form["fuel_type"], fueled_at.date(), price_region); wholesale=p["price_per_liter"]; source=p["source"]
except Exception: pass
station_label = station_company.company_name if station_company else request.form.get("station")
e = FuelEntry(vehicle_id=vehicle.id, user_id=current_user.id, fueled_at=fueled_at, liters=Decimal(request.form["liters"]), price_per_liter=Decimal(request.form["price_per_liter"]), fuel_type=request.form["fuel_type"], odometer=odometer, station=station_label, station_company_id=station_company.id if station_company else None, invoice_number=request.form.get("invoice_number"), used_fuel_card="used_fuel_card" in request.form, wholesale_price=wholesale, wholesale_source=source)
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); socketio.emit("fuel_added", {"vehicle": vehicle.name, "gross": round(e.gross,2)}); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard"))
settings=current_user.company or CompanySettings.query.first()
allowed=list(settings.allowed_stations)
base_query=FuelStationCompany.query.filter_by(active=True)
stations=(base_query.filter(FuelStationCompany.id.in_([x.id for x in allowed])) if allowed else base_query).order_by(FuelStationCompany.station_count.desc(), FuelStationCompany.brand_name.asc()).all()
favorites=[x for x in current_user.favorite_stations if x in stations]
favorite_scope="user"
if not favorites and settings.favorite_stations:
favorites=[x for x in settings.favorite_stations if x in stations];favorite_scope="company"
if not favorites: favorites=sorted(stations,key=lambda x:(-x.station_count,x.company_name.lower()))[:10];favorite_scope="default"
favorites=sorted(favorites,key=lambda x:(-x.station_count,x.display_name.lower()))
favorite_ids={x.id for x in favorites}
favorite_catalog=sorted(stations,key=lambda x:(-x.station_count,x.display_name.lower()))
other_stations=sorted((x for x in stations if x.id not in favorite_ids),key=lambda x:x.display_name.lower())
return render_template("fuel_form.html", vehicles=vehicles, stations=stations, favorite_stations=favorites, favorite_catalog=favorite_catalog, other_stations=other_stations, now=datetime.now().strftime("%Y-%m-%dT%H:%M"), restricted=bool(allowed), favorite_scope=favorite_scope, company=settings, fuel_cards=FuelCard.query.filter_by(active=True,company_id=settings.id).order_by(FuelCard.name).all())
@main.route("/api/orlen-price")
@login_required
def orlen_price():
try: return jsonify(fetch_orlen_price(request.args["fuel_type"], datetime.fromisoformat(request.args["date"]).date(), CompanySettings.query.first().region))
except Exception as exc: return jsonify({"error": str(exc)}), 502
@main.route("/orlen")
@login_required
def orlen_data():
settings = CompanySettings.query.first()
selected_year = request.args.get("year", type=int) or date.today().year
selected_fuels = [f.upper() for f in request.args.getlist("fuel") if f.upper() in FUEL_TYPES] or ["PB95"]
include_vat = request.args.get("gross") == "1"
show_lpg_regions = request.args.get('show_lpg_regions') == '1'
selected_regions = [r.lower() for r in request.args.getlist("region") if r.lower() in POLISH_REGIONS]
if not selected_regions: selected_regions=[settings.region.lower()]
if not show_lpg_regions: selected_regions=[settings.region.lower()]
query = OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(selected_fuels), extract("year", OrlenPrice.effective_date)==selected_year)
rows = query.order_by(OrlenPrice.effective_date, OrlenPrice.fuel_type).all()
chart = {}
for fuel in selected_fuels:
fuel_rows = [r for r in rows if r.fuel_type == fuel and (fuel != "LPG" or r.region in selected_regions)]
multiplier = 1 + float(settings.vat_rate) / 100 if include_vat else 1
chart_key = fuel if fuel != "LPG" else None
if fuel == "LPG":
for region in selected_regions:
chart[f"LPG · {region}"] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows if r.region == region]
else:
chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows]
return render_template("orlen.html", rows=rows, chart=chart, selected_fuels=selected_fuels, selected_year=selected_year, settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat, regions=POLISH_REGIONS, selected_regions=selected_regions, show_lpg_regions=show_lpg_regions)
@main.post("/orlen/sync")
@login_required
@roles("boss", "admin")
def orlen_sync():
payload = request.get_json(silent=True) or request.form
fuels = payload.get("fuels", [])
if isinstance(fuels, str): fuels = [fuels]
fuels = [f.upper() for f in fuels if f.upper() in FUEL_TYPES]
year = int(payload.get("year", date.today().year))
if not fuels: return jsonify({"ok": False, "error": "Wybierz co najmniej jedno paliwo."}), 400
settings = CompanySettings.query.first(); start = date(year,1,1); end = min(date(year,12,31), date.today())
result = {}; total = 0
try:
for fuel in fuels:
imported = 0; updated = 0
for row in fetch_orlen_range(fuel, start, end, "all" if fuel == "LPG" else settings.region):
existing = OrlenPrice.query.filter_by(fuel_type=row["fuel_type"], effective_date=row["effective_date"], region=row["region"]).first()
if existing:
existing.price_per_liter=row["price_per_liter"]; existing.raw_value=row["raw_value"]; existing.product_name=row["product_name"]; existing.source=row["source"]; existing.fetched_at=datetime.utcnow(); updated += 1
else:
db.session.add(OrlenPrice(**row)); imported += 1
result[fuel] = {"added": imported, "updated": updated}; total += imported + updated
db.session.commit()
return jsonify({"ok": True, "message": f"Przetworzono {total} rekordów.", "result": result})
except Exception as exc:
db.session.rollback(); return jsonify({"ok": False, "error": str(exc)}), 502
@main.route("/admin", methods=["GET","POST"])
@login_required
@roles("boss", "admin")
def admin():
if request.method=="POST":
settings=current_user.company or CompanySettings.query.first()
settings.name=request.form.get("name",settings.name);settings.entity_type=request.form.get("entity_type",settings.entity_type);settings.vat_rate=Decimal(request.form.get("vat_rate",settings.vat_rate));settings.vat_deduction_percent=Decimal(request.form.get("vat_deduction_percent",settings.vat_deduction_percent));settings.region=request.form.get("region",settings.region);settings.invoice_split_day=int(request.form.get("invoice_split_day",settings.invoice_split_day))
theme=request.form.get("theme")
if theme in THEMES:db.session.merge(AppSetting(key="theme",value=theme))
db.session.commit();return ok_response("Zapisano") or redirect(url_for("main.admin_companies"))
return redirect(url_for("main.admin_companies"))
@main.get("/admin/companies")
@login_required
@roles("boss", "admin")
def admin_companies():
q = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
selected_id = request.args.get("company_id", type=int)
companies_query = CompanySettings.query
if current_user.role == "boss":
companies_query = companies_query.filter_by(id=current_user.company_id)
elif q:
companies_query = companies_query.filter(CompanySettings.name.ilike(f"%{q}%"))
companies_page = companies_query.order_by(CompanySettings.name.asc()).paginate(
page=page, per_page=20, error_out=False
)
visible_companies = companies_page.items
if current_user.role == "boss":
selected_company = current_user.company
else:
selected_company = db.session.get(CompanySettings, selected_id) if selected_id else None
if selected_company is None:
selected_company = visible_companies[0] if visible_companies else CompanySettings.query.order_by(CompanySettings.name.asc()).first()
if selected_company and current_user.role == "boss" and selected_company.id != current_user.company_id:
abort(403)
fuel_cards = FuelCard.query.filter_by(company_id=selected_company.id).order_by(FuelCard.name.asc()).all() if selected_company else []
stations = FuelStationCompany.query.filter_by(active=True).order_by(
FuelStationCompany.station_count.desc(), FuelStationCompany.brand_name.asc()
).all()
return render_template(
"admin_companies.html",
companies_page=companies_page,
selected_company=selected_company,
fuel_cards=fuel_cards,
q=q,
regions=POLISH_REGIONS,
stations=stations,
)
@main.get("/admin/application")
@login_required
@roles("admin")
def admin_application():
return render_template("admin_application.html")
@main.get("/admin/users")
@login_required
@roles("boss", "admin")
def admin_users():
page=request.args.get("page",1,type=int);q=request.args.get("q","").strip();users_q=User.query
if current_user.role=="boss": users_q=users_q.filter_by(company_id=current_user.company_id)
if q: users_q=users_q.filter(db.or_(User.name.ilike(f"%{q}%"),User.email.ilike(f"%{q}%")))
users_page=users_q.order_by(User.name).paginate(page=page,per_page=25,error_out=False)
companies=CompanySettings.query.order_by(CompanySettings.name).all() if current_user.role=="admin" else [current_user.company]
cards=FuelCard.query.order_by(FuelCard.name).all()
if current_user.role=="boss": cards=cards.filter_by(company_id=current_user.company_id).all()
return render_template("admin_users.html",users_page=users_page,q=q,companies=[c for c in companies if c],fuel_cards=cards)
@main.post("/admin/users/<int:user_id>")
@login_required
@roles("admin")
def edit_user(user_id):
user = User.query.get_or_404(user_id)
try:
user.name = request.form["name"].strip(); user.email = request.form["email"].strip().lower(); user.role = request.form["role"]
user.active = "active" in request.form
password = request.form.get("password", "")
if password:
if len(password) < 8: raise ValueError("Nowe hasło musi mieć co najmniej 8 znaków.")
user.set_password(password)
db.session.commit(); response = ok_response(f"Zaktualizowano użytkownika {user.email}"); return response or redirect(url_for("main.admin"))
except (ValueError, IntegrityError) as exc:
db.session.rollback(); message = str(exc.orig) if isinstance(exc, IntegrityError) else str(exc); response = error_response(message); return response or redirect(url_for("main.admin", _anchor=f"user-{user_id}"))
@main.route("/stations")
@login_required
@roles("boss", "admin")
def stations():
q = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
sort = request.args.get("sort", "company_name")
direction = request.args.get("direction", "asc")
query = FuelStationCompany.query.filter_by(active=True)
if q:
terms = [term for term in q.split() if term]
for term in terms:
pattern = f"%{term}%"
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.nip.ilike(pattern), FuelStationCompany.regon.ilike(pattern), FuelStationCompany.station_names.ilike(pattern)))
columns = {"company_name": FuelStationCompany.brand_name, "station_count": FuelStationCompany.station_count, "nip": FuelStationCompany.nip, "regon": FuelStationCompany.regon, "selected_region": FuelStationCompany.selected_region}
column = columns.get(sort, FuelStationCompany.company_name)
query = query.order_by(column.desc() if direction == "desc" else column.asc(), FuelStationCompany.brand_name.asc())
station_page = query.paginate(page=page, per_page=30, error_out=False)
companies_q = CompanySettings.query.filter_by(active=True).order_by(CompanySettings.name.asc())
if current_user.role == "boss":
companies_q = companies_q.filter_by(id=current_user.company_id)
companies = companies_q.all()
requested_company_id = request.args.get("company_id", type=int)
if current_user.role == "boss":
company = current_user.company
else:
company = db.session.get(CompanySettings, requested_company_id) if requested_company_id else None
if company not in companies:
company = companies[0] if companies else None
if company is None:
abort(404, description="Brak aktywnej firmy do konfiguracji")
cards_q=FuelCard.query.filter_by(active=True, company_id=company.id)
cards=cards_q.order_by(FuelCard.name).all()
rules={(r.fuel_card_id,r.station_company_id):r for r in FuelCardStationRule.query.filter(FuelCardStationRule.fuel_card_id.in_([c.id for c in cards] or [-1])).all()}
return render_template("stations.html", station_page=station_page, q=q, regions=POLISH_REGIONS, sort=sort, direction=direction, allowed_ids={x.id for x in company.allowed_stations}, favorite_ids={x.id for x in company.favorite_stations}, company=company, companies=companies, fuel_cards=cards, card_rules=rules)
@main.post("/stations/sync")
@login_required
@roles("boss", "admin")
def stations_sync():
try:
companies = aggregate_ure_companies(fetch_ure_stations())
added, updated = sync_station_catalog(companies)
db.session.commit()
response = ok_response(f"Katalog URE: dodano {added}, zaktualizowano {updated}.")
return response or redirect(url_for("main.stations"))
except Exception as exc:
db.session.rollback(); response = error_response(f"Nie udało się pobrać danych URE: {exc}", 502); return response or redirect(url_for("main.stations"))
@main.post("/stations/<int:station_id>")
@login_required
@roles("boss", "admin")
def update_station(station_id):
station = FuelStationCompany.query.get_or_404(station_id)
region = request.form.get("selected_region", "").strip().lower()
if region and region not in station.region_list:
return error_response("Wybrane województwo nie występuje w danych tej firmy.")
station.selected_region = region
station.active = "active" in request.form
db.session.commit(); response = ok_response(f"Zapisano ustawienia firmy {station.company_name}"); return response or redirect(url_for("main.stations"))
@main.get("/api/stations")
@login_required
def station_suggestions():
q = request.args.get("q", "").strip()
query = FuelStationCompany.query.filter_by(active=True)
if q:
pattern = f"%{q}%"
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.station_names.ilike(pattern), FuelStationCompany.nip.ilike(pattern)))
rows = query.order_by(FuelStationCompany.company_name).limit(30).all()
return jsonify([{"id": x.id, "name": x.company_name, "brands": x.station_name_list[:5], "region": x.selected_region, "last_price": x.use_orlen_last_price} for x in rows])
+205
View File
@@ -0,0 +1,205 @@
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from .extensions import db
vehicle_users = db.Table(
"vehicle_users",
db.Column("vehicle_id", db.Integer, db.ForeignKey("vehicle.id"), primary_key=True),
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
)
user_favorite_stations = db.Table(
"user_favorite_stations",
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
)
company_favorite_stations = db.Table(
"company_favorite_stations",
db.Column("settings_id", db.Integer, db.ForeignKey("company_settings.id"), primary_key=True),
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
)
company_allowed_stations = db.Table(
"company_allowed_stations",
db.Column("settings_id", db.Integer, db.ForeignKey("company_settings.id"), primary_key=True),
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(160), unique=True, nullable=False, index=True)
name = db.Column(db.String(120), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default="user")
active = db.Column(db.Boolean, default=True, nullable=False)
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
owned_vehicles = db.relationship("Vehicle", back_populates="owner", foreign_keys="Vehicle.owner_id")
shared_vehicles = db.relationship("Vehicle", secondary=vehicle_users, back_populates="drivers")
fuel_card = db.relationship("FuelCard", foreign_keys=[fuel_card_id])
company = db.relationship("CompanySettings", foreign_keys=[company_id], back_populates="users")
def set_password(self, password): self.password_hash = generate_password_hash(password)
def check_password(self, password): return check_password_hash(self.password_hash, password)
@property
def is_active(self): return self.active
class CompanySettings(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(160), default="Moja firma", nullable=False)
entity_type = db.Column(db.String(30), default="JDG", nullable=False)
vat_rate = db.Column(db.Numeric(5, 2), default=23, nullable=False)
vat_deduction_percent = db.Column(db.Numeric(5, 2), default=50, nullable=False)
region = db.Column(db.String(60), default="mazowieckie", nullable=False)
invoice_split_day = db.Column(db.Integer, default=15, nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False)
users = db.relationship("User", back_populates="company", foreign_keys="User.company_id")
class AppSetting(db.Model):
key = db.Column(db.String(80), primary_key=True)
value = db.Column(db.String(255), nullable=False)
class FuelCard(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True, index=True)
provider = db.Column(db.String(120), nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False)
description = db.Column(db.String(255))
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
station_rules = db.relationship("FuelCardStationRule", back_populates="fuel_card", cascade="all, delete-orphan")
company = db.relationship("CompanySettings", foreign_keys=[company_id])
class Vehicle(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
make = db.Column(db.String(80), nullable=False)
model = db.Column(db.String(80), nullable=False)
registration = db.Column(db.String(20), unique=True, nullable=False)
fuel_type = db.Column(db.String(20), nullable=False, default="PB95")
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
has_fuel_card = db.Column(db.Boolean, default=False, nullable=False)
card_provider = db.Column(db.String(80))
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
current_odometer = db.Column(db.Integer, default=0, nullable=False)
owner = db.relationship("User", back_populates="owned_vehicles", foreign_keys=[owner_id])
drivers = db.relationship("User", secondary=vehicle_users, back_populates="shared_vehicles")
fuel_card = db.relationship("FuelCard", foreign_keys=[fuel_card_id])
company = db.relationship("CompanySettings", foreign_keys=[company_id])
fuel_entries = db.relationship("FuelEntry", back_populates="vehicle", cascade="all, delete-orphan")
class FuelStationCompany(db.Model):
id = db.Column(db.Integer, primary_key=True)
ure_key = db.Column(db.String(180), unique=True, nullable=False, index=True)
brand_name = db.Column(db.String(120), nullable=False, default="", index=True)
company_name = db.Column(db.String(220), nullable=False, index=True)
nip = db.Column(db.String(20), index=True)
regon = db.Column(db.String(20), index=True)
station_names = db.Column(db.Text, default="", nullable=False)
regions = db.Column(db.Text, default="", nullable=False)
selected_region = db.Column(db.String(60), default="", nullable=False)
station_count = db.Column(db.Integer, default=0, nullable=False)
has_petrol = db.Column(db.Boolean, default=False, nullable=False)
has_diesel = db.Column(db.Boolean, default=False, nullable=False)
has_lpg = db.Column(db.Boolean, default=False, nullable=False)
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False)
fetched_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
points = db.relationship("FuelStationPoint", back_populates="station_company", cascade="all, delete-orphan", lazy="dynamic")
@property
def region_list(self): return [x for x in (self.regions or "").split("|") if x]
@property
def station_name_list(self): return [x for x in (self.station_names or "").split("|") if x]
@property
def display_name(self): return self.brand_name or self.company_name
class FuelStationPoint(db.Model):
id = db.Column(db.Integer, primary_key=True)
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), nullable=False, index=True)
ure_dkn = db.Column(db.String(40), nullable=False, index=True)
station_name = db.Column(db.String(220), nullable=False, default="")
street = db.Column(db.String(180), nullable=False, default="")
street_number = db.Column(db.String(40), nullable=False, default="")
postal_code = db.Column(db.String(20), nullable=False, default="")
city = db.Column(db.String(120), nullable=False, default="")
region = db.Column(db.String(60), nullable=False, default="")
coordinates = db.Column(db.String(120), nullable=False, default="")
has_petrol = db.Column(db.Boolean, default=False, nullable=False)
has_diesel = db.Column(db.Boolean, default=False, nullable=False)
has_lpg = db.Column(db.Boolean, default=False, nullable=False)
station_company = db.relationship("FuelStationCompany", back_populates="points")
__table_args__ = (db.UniqueConstraint("station_company_id", "ure_dkn", name="uq_station_company_point_dkn"),)
@property
def address(self):
line = " ".join(x for x in (self.street, self.street_number) if x).strip()
locality = " ".join(x for x in (self.postal_code, self.city) if x).strip()
return ", ".join(x for x in (line, locality) if x) or "Brak adresu"
class FuelCardStationRule(db.Model):
id = db.Column(db.Integer, primary_key=True)
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), nullable=False, index=True)
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), nullable=False, index=True)
allowed = db.Column(db.Boolean, default=True, nullable=False)
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
discount_net_percent = db.Column(db.Numeric(6, 3), default=0, nullable=False)
surcharge_net_per_liter = db.Column(db.Numeric(10, 4), default=0, nullable=False)
fuel_card = db.relationship("FuelCard", back_populates="station_rules")
station_company = db.relationship("FuelStationCompany")
__table_args__ = (db.UniqueConstraint("fuel_card_id", "station_company_id", name="uq_card_station_rule"),)
class FuelEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
fueled_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
liters = db.Column(db.Numeric(10, 3), nullable=False)
price_per_liter = db.Column(db.Numeric(10, 4), nullable=False)
fuel_type = db.Column(db.String(20), nullable=False)
odometer = db.Column(db.Integer, nullable=False)
station = db.Column(db.String(120))
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), index=True)
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
invoice_number = db.Column(db.String(80))
used_fuel_card = db.Column(db.Boolean, default=False, nullable=False)
wholesale_price = db.Column(db.Numeric(10, 4))
wholesale_source = db.Column(db.String(120))
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
vehicle = db.relationship("Vehicle", back_populates="fuel_entries")
user = db.relationship("User")
station_company = db.relationship("FuelStationCompany")
fuel_card = db.relationship("FuelCard")
@property
def gross(self): return float(self.liters) * float(self.price_per_liter)
class OrlenPrice(db.Model):
id = db.Column(db.Integer, primary_key=True)
fuel_type = db.Column(db.String(20), nullable=False, index=True)
effective_date = db.Column(db.Date, nullable=False, index=True)
price_per_liter = db.Column(db.Numeric(10, 4), nullable=False)
raw_value = db.Column(db.Numeric(14, 4), nullable=False)
region = db.Column(db.String(60), nullable=False, default="")
product_name = db.Column(db.String(80), nullable=False)
source = db.Column(db.String(160), nullable=False)
fetched_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (db.UniqueConstraint("fuel_type", "effective_date", "region", name="uq_orlen_price"),)
class FuelCardPolicy(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"), nullable=False, unique=True, index=True)
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
discount_percent = db.Column(db.Numeric(6, 3), default=0, nullable=False)
surcharge_per_liter = db.Column(db.Numeric(10, 4), default=0, nullable=False)
description = db.Column(db.String(160))
vehicle = db.relationship("Vehicle", backref=db.backref("fuel_card_policy", uselist=False, cascade="all, delete-orphan"))
User.favorite_stations = db.relationship("FuelStationCompany", secondary=user_favorite_stations, backref=db.backref("favorite_users", lazy="dynamic"))
CompanySettings.allowed_stations = db.relationship("FuelStationCompany", secondary=company_allowed_stations, backref=db.backref("allowed_for_companies", lazy="dynamic"))
CompanySettings.favorite_stations = db.relationship("FuelStationCompany", secondary=company_favorite_stations, backref=db.backref("favorite_for_companies", lazy="dynamic"))
+46
View File
@@ -0,0 +1,46 @@
from flask import Blueprint, jsonify, Response
docs = Blueprint('openapi', __name__)
@docs.get('/openapi.json')
def spec():
return jsonify({
'openapi':'3.0.3','info':{'title':'FuelTrack API','version':'0.0.1','description':'REST API używane przez interfejs FuelTrack. Autoryzacja odbywa się przez sesję Flask (cookie).'},
'components':{'securitySchemes':{'sessionCookie':{'type':'apiKey','in':'cookie','name':'session'}},'schemas':{
'ApiResponse':{'type':'object','properties':{'ok':{'type':'boolean'},'message':{'type':'string'},'data':{}}},
'User':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'email':{'type':'string','format':'email'},'role':{'type':'string','enum':['user','boss','admin']},'active':{'type':'boolean'}}},
'Vehicle':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'registration':{'type':'string'},'fuel_type':{'type':'string','enum':['PB95','PB98','DIESEL','LPG']}}}
}},
'security':[{'sessionCookie':[]}],
'paths':{
'/api/auth/login':{'post':{'tags':['Auth'],'security':[],'summary':'Logowanie','requestBody':{'required':True,'content':{'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string'},'password':{'type':'string'}}}}}},'responses':{'200':{'description':'Zalogowano'},'401':{'description':'Błędne dane'}}}},
'/api/auth/logout':{'post':{'tags':['Auth'],'summary':'Wylogowanie','responses':{'200':{'description':'Wylogowano'}}}},
'/api/me':{'get':{'tags':['Auth'],'summary':'Bieżący użytkownik','responses':{'200':{'description':'Użytkownik'}}}},
'/api/vehicles':{'get':{'tags':['Vehicles'],'summary':'Lista dostępnych pojazdów','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Vehicles'],'summary':'Dodanie pojazdu','responses':{'201':{'description':'Utworzono'}}}},
'/api/vehicles/{vehicle_id}/shares':{'post':{'tags':['Vehicles'],'summary':'Udostępnienie pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Udostępniono'}}}},
'/api/vehicles/{vehicle_id}/shares/{user_id}':{'delete':{'tags':['Vehicles'],'summary':'Odebranie dostępu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}},{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Odebrano'}}}},
'/api/vehicles/{vehicle_id}/fuel-card':{'put':{'tags':['Vehicles'],'summary':'Przypisanie karty paliwowej do pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
'/api/fuel-entries':{'post':{'tags':['Fuel entries'],'summary':'Dodanie tankowania','responses':{'201':{'description':'Utworzono'}}}},
'/api/orlen/prices':{'get':{'tags':['Orlen'],'summary':'Ceny zapisane w bazie','parameters':[{'in':'query','name':'year','schema':{'type':'integer'}},{'in':'query','name':'fuel','schema':{'type':'array','items':{'type':'string'}}},{'in':'query','name':'region','schema':{'type':'array','items':{'type':'string'}}}],'responses':{'200':{'description':'Lista cen'}}}},
'/api/orlen/sync':{'post':{'tags':['Orlen'],'summary':'Import cen Orlen','responses':{'200':{'description':'Zaimportowano'}}}},
'/api/orlen/preview':{'get':{'tags':['Orlen'],'summary':'Cena na dzień','parameters':[{'in':'query','name':'fuel_type','required':True,'schema':{'type':'string'}},{'in':'query','name':'date','required':True,'schema':{'type':'string','format':'date'}}],'responses':{'200':{'description':'Cena'}}}},
'/api/users':{'get':{'tags':['Users'],'summary':'Lista użytkowników','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Users'],'summary':'Dodanie użytkownika','responses':{'201':{'description':'Utworzono'}}}},
'/api/users/{user_id}':{'put':{'tags':['Users'],'summary':'Edycja użytkownika','parameters':[{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}},'delete':{'tags':['Users'],'summary':'Usunięcie użytkownika','description':'Nie pozwala usunąć własnego konta, ostatniego aktywnego administratora ani użytkownika z pojazdami lub historią tankowań.','parameters':[{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Usunięto'},'409':{'description':'Usunięcie zablokowane'}}}},
'/api/settings':{'get':{'tags':['Settings'],'summary':'Pobranie ustawień','responses':{'200':{'description':'Ustawienia'}}},'put':{'tags':['Settings'],'summary':'Zapis ustawień','responses':{'200':{'description':'Zapisano'}}}},
'/api/app-settings':{'get':{'tags':['Settings'],'summary':'Ustawienia aplikacji','responses':{'200':{'description':'Ustawienia'}}},'put':{'tags':['Settings'],'summary':'Zmiana motywu aplikacji','responses':{'200':{'description':'Zapisano'}}}},
'/api/fuel-cards':{'get':{'tags':['Fuel cards'],'summary':'Lista kart paliwowych','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Fuel cards'],'summary':'Dodanie karty paliwowej','responses':{'201':{'description':'Utworzono'}}}},
'/api/fuel-cards/{card_id}':{'put':{'tags':['Fuel cards'],'summary':'Edycja karty paliwowej','parameters':[{'in':'path','name':'card_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
'/api/fuel-cards/{card_id}/stations/{station_id}':{'put':{'tags':['Fuel cards'],'summary':'Warunki karty dla stacji: blokada, last price, rabat netto i dopłata','parameters':[{'in':'path','name':'card_id','required':True,'schema':{'type':'integer'}},{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
'/api/stations':{'get':{'tags':['Stations'],'summary':'Lista firm paliwowych','parameters':[{'in':'query','name':'q','schema':{'type':'string'}},{'in':'query','name':'sort','schema':{'type':'string','enum':['company_name','station_count','nip','regon','selected_region']}},{'in':'query','name':'direction','schema':{'type':'string','enum':['asc','desc']}},{'in':'query','name':'page','schema':{'type':'integer'}}],'responses':{'200':{'description':'Lista'}}}},
'/api/stations/{station_id}/points':{'get':{'tags':['Stations'],'summary':'Lista fizycznych punktów wybranej firmy paliwowej','parameters':[{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}},{'in':'query','name':'q','schema':{'type':'string'}},{'in':'query','name':'page','schema':{'type':'integer'}}],'responses':{'200':{'description':'Lista punktów i adresów'}}}},
'/api/me/favorite-stations':{'put':{'tags':['Stations'],'summary':'Zapis maksymalnie 10 ulubionych sieci bieżącego użytkownika','responses':{'200':{'description':'Zapisano'}}}},
'/api/settings/allowed-stations':{'put':{'tags':['Stations'],'summary':'Zapis listy sieci dozwolonych przez firmę','responses':{'200':{'description':'Zapisano'}}}},
'/api/stations/sync':{'post':{'tags':['Stations'],'summary':'Import i agregacja URE','responses':{'200':{'description':'Zaimportowano'}}}},
'/api/stations/{station_id}':{'put':{'tags':['Stations'],'summary':'Edycja firmy paliwowej','parameters':[{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}}
}
})
@docs.get('/docs')
def swagger_docs():
return Response("""<!doctype html><html><head><meta charset='utf-8'><title>FuelTrack API Docs</title><link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css'></head><body><div id='swagger-ui'></div><script src='https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js'></script><script>SwaggerUIBundle({url:'/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true});</script></body></html>""", mimetype='text/html')
+8
View File
@@ -0,0 +1,8 @@
"""Compatibility facade. New code lives in domain/ and integrations/."""
from .domain.costs import calculate_costs, money
from .domain.invoices import invoice_period
from .integrations.orlen import fetch_orlen_price, fetch_orlen_range, POLISH_REGIONS, PRODUCT_IDS
from .integrations.ure import fetch_ure_stations, aggregate_ure_companies
__all__ = ["calculate_costs", "money", "invoice_period", "fetch_orlen_price", "fetch_orlen_range",
"POLISH_REGIONS", "PRODUCT_IDS", "fetch_ure_stations", "aggregate_ure_companies"]
+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}})}}})();
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from .extensions import db
from .models import FuelStationCompany, FuelStationPoint
def sync_station_catalog(companies):
added = updated = 0
incoming_keys = {row["ure_key"] for row in companies}
for source in companies:
row = dict(source)
point_rows = row.pop("points", [])
with db.session.no_autoflush:
company = FuelStationCompany.query.filter_by(ure_key=row["ure_key"]).first()
if company is None:
company = FuelStationCompany(**row, active=True)
db.session.add(company); db.session.flush(); added += 1
else:
for key, value in row.items(): setattr(company, key, value)
company.active = True
company.fetched_at = datetime.utcnow(); updated += 1
existing = {p.ure_dkn: p for p in company.points.all()}
incoming = set()
for data in point_rows:
key = data["ure_dkn"]; incoming.add(key)
point = existing.get(key)
if point is None:
point = FuelStationPoint(station_company_id=company.id, ure_dkn=key); db.session.add(point)
for field, value in data.items(): setattr(point, field, value)
for key, point in existing.items():
if key not in incoming: db.session.delete(point)
company.station_count = len(incoming)
FuelStationCompany.query.filter(~FuelStationCompany.ure_key.in_(incoming_keys)).update({FuelStationCompany.active: False}, synchronize_session=False)
db.session.flush()
return added, updated
+5
View File
@@ -0,0 +1,5 @@
<ul class="nav nav-tabs mb-4">
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_companies' %}active{% endif %}" href="{{url_for('main.admin_companies')}}">Firmy i karty</a></li>
{% if current_user.role=='admin' %}<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_application' %}active{% endif %}" href="{{url_for('main.admin_application')}}">Aplikacja</a></li>{% endif %}
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_users' %}active{% endif %}" href="{{url_for('main.admin_users')}}">Użytkownicy</a></li>
</ul>
+17
View File
@@ -0,0 +1,17 @@
{% extends 'base.html' %}{% block content %}
<div class="mb-4"><h1 class="h2 mb-1">Administracja</h1><p class="text-body-secondary mb-0">Ustawienia firmy i zarządzanie użytkownikami.</p></div>
<div class="row g-4"><div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">Ustawienia firmy</h2>
<form method="post" action="/api/settings" data-method="PUT" class="row g-3 ajax-form">
<div class="col-12"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" value="{{settings.name}}" required></div><div class="col-6"><label class="form-label">Typ firmy</label><select class="form-select" name="entity_type"><option {% if settings.entity_type=='JDG' %}selected{% endif %}>JDG</option><option>Spółka</option></select></div><div class="col-6"><label class="form-label">VAT %</label><input class="form-control" type="number" step="0.01" name="vat_rate" value="{{settings.vat_rate}}"></div>
<div class="col-6"><label class="form-label">Odliczenie VAT %</label><input class="form-control" type="number" min="0" max="100" step="1" name="vat_deduction_percent" value="{{settings.vat_deduction_percent}}"></div>
<div class="col-6"><label class="form-label">Dzień podziału</label><input class="form-control" type="number" min="1" max="27" name="invoice_split_day" value="{{settings.invoice_split_day}}"></div>
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="invoice_split_enabled" id="invoice-split" {% if split_enabled %}checked{% endif %}><label class="form-check-label" for="invoice-split">Dziel faktury kartowe według dnia miesiąca</label></div></div>
<div class="col-12"><label class="form-label">Województwo dla LPG</label><select class="form-select" name="region" required>{% for region in regions %}<option value="{{region}}" {% if settings.region==region %}selected{% endif %}>{{region}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia</button></div></form></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">Dodaj użytkownika</h2><form method="post" action="/api/users" class="row g-3 ajax-form"><div class="col-md-6"><label class="form-label">Imię i nazwisko</label><input class="form-control" name="user_name" required></div><div class="col-md-6"><label class="form-label">E-mail</label><input class="form-control" type="email" name="email" required></div><div class="col-md-6"><label class="form-label">Hasło</label><input class="form-control" type="password" name="password" minlength="8" required></div><div class="col-md-6"><label class="form-label">Rola</label><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option><option value="admin">Admin</option></select></div><div class="col-12"><button class="btn btn-primary">Dodaj użytkownika</button></div></form></div></div></div></div>
<div class="row g-4 mt-1">
<div class="col-xl-5"><div class="card"><div class="card-body"><h2 class="h5">Ustawienia aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-12"><label class="form-label">Motyw aplikacji</label><select class="form-select" name="theme">{% for key,item in themes.items() %}<option value="{{key}}" {% if app_theme_name==key %}selected{% endif %}>{{item.label}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div></div>
<div class="col-xl-7"><div class="card"><div class="card-body"><h2 class="h5">Karty paliwowe firmy</h2><form action="/api/fuel-cards" class="ajax-form row g-2 mb-3"><div class="col-md-4"><input class="form-control" name="name" placeholder="Nazwa, np. UTA Polska" required></div><div class="col-md-3"><input class="form-control" name="provider" placeholder="Operator" required></div><div class="col-md-3"><input class="form-control" name="description" placeholder="Opis"></div><div class="col-md-2"><button class="btn btn-primary w-100">Dodaj</button></div></form><div class="list-group">{% for card in fuel_cards %}<div class="list-group-item d-flex justify-content-between align-items-center"><div><strong>{{card.name}}</strong><div class="small text-body-secondary">{{card.provider}}{% if card.description %} · {{card.description}}{% endif %}</div></div><span class="badge text-bg-{{'success' if card.active else 'secondary'}}">{{'aktywna' if card.active else 'nieaktywna'}}</span></div>{% else %}<div class="text-body-secondary">Brak kart paliwowych.</div>{% endfor %}</div></div></div></div>
</div>
{% if current_user.role=='admin' %}<div class="card mt-4"><div class="card-body"><div class="d-flex flex-column flex-md-row justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Użytkownicy</h2><small class="text-body-secondary">{{ users_page.total }} kont</small></div><form method="get" action="{{url_for('main.admin')}}" class="d-flex gap-2 ajax-nav-form"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj po nazwie lub e-mailu"><button class="btn btn-secondary">Szukaj</button></form></div><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Nazwa</th><th>E-mail</th><th>Rola</th><th>Status</th><th class="text-end">Akcja</th></tr></thead><tbody>{% for u in users_page.items %}<tr><td>{{u.name}}</td><td>{{u.email}}</td><td><span class="badge text-bg-secondary">{{u.role}}</span></td><td>{% if u.active %}<span class="badge text-bg-success">Aktywny</span>{% else %}<span class="badge text-bg-danger">Nieaktywny</span>{% endif %}</td><td class="text-end"><button type="button" class="btn btn-sm btn-primary user-edit-btn" data-bs-toggle="modal" data-bs-target="#userEditModal" data-id="{{u.id}}" data-name="{{u.name|e}}" data-email="{{u.email|e}}" data-role="{{u.role}}" data-active="{{1 if u.active else 0}}" data-card="{{u.fuel_card_id or ''}}">Edytuj</button></td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak użytkowników.</td></tr>{% endfor %}</tbody></table></div>{% if users_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in users_page.iter_pages() %}{% if p %}<li class="page-item {% if p==users_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.admin',page=p,q=q)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}</div></div>
<div class="modal fade" id="userEditModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content"><form method="post" class="ajax-form" id="user-edit-form" data-method="PUT"><div class="modal-header"><h2 class="modal-title fs-5">Edytuj użytkownika</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Nazwa</label><input class="form-control" name="name" required></div><div class="mb-3"><label class="form-label">E-mail</label><input class="form-control" type="email" name="email" required></div><div class="mb-3"><label class="form-label">Rola</label><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option><option value="admin">Admin</option></select></div><div class="mb-3"><label class="form-label">Domyślna karta paliwowa</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards if card.active %}<option value="{{card.id}}">{{card.name}}</option>{% endfor %}</select></div><div class="mb-3"><label class="form-label">Nowe hasło</label><input class="form-control" type="password" name="password" minlength="8" placeholder="Pozostaw puste bez zmiany"></div><div class="form-check"><input class="form-check-input" type="checkbox" name="active" id="modal-active"><label class="form-check-label" for="modal-active">Konto aktywne</label></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>{% endif %}
{% endblock %}{% block scripts %}<script>FuelTrack.bindUserModal();</script>{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %}
<h1 class="h2 mb-3">Administracja</h1>{% include '_admin_nav.html' %}
<div class="card"><div class="card-body"><h2 class="h5">Wygląd aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-md-6"><label class="form-label">Motyw Bootstrap</label><select class="form-select" name="theme">{% for key,t in themes.items() %}<option value="{{key}}" {% if key==app_theme_name %}selected{% endif %}>{{t.label}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends 'base.html' %}
{% block title %}Firmy i karty{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div><h1 class="h2 mb-1">Administracja</h1><p class="text-body-secondary mb-0">Firmy, karty paliwowe i firmowe ulubione stacje.</p></div>
{% if current_user.role == 'admin' %}<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCompanyModal">Dodaj firmę</button>{% endif %}
</div>
{% include '_admin_nav.html' %}
<div class="row g-4">
<div class="col-xl-4 col-xxl-3">
<div class="card h-100">
<div class="card-header"><strong>Firmy</strong></div>
<div class="card-body border-bottom">
<form method="get" action="{{ url_for('main.admin_companies') }}" class="ajax-nav-form">
<div class="input-group"><input class="form-control" name="q" value="{{ q }}" placeholder="Szukaj firmy"><button class="btn btn-secondary">Szukaj</button></div>
</form>
</div>
<div class="list-group list-group-flush">
{% for c in companies_page.items %}
<a href="{{ url_for('main.admin_companies', company_id=c.id, q=q, page=companies_page.page) }}" class="list-group-item list-group-item-action ajax-nav-link {% if selected_company and selected_company.id == c.id %}active{% endif %}">
<div class="d-flex justify-content-between gap-2"><strong class="text-truncate">{{ c.name }}</strong><span class="badge {% if c.active %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{ 'aktywna' if c.active else 'wyłączona' }}</span></div>
<div class="small {% if selected_company and selected_company.id == c.id %}text-white-50{% else %}text-body-secondary{% endif %}">{{ c.entity_type }} · {{ c.region }} · {{ c.users|length }} użytk.</div>
</a>
{% else %}<div class="p-4 text-center text-body-secondary">Brak firm.</div>{% endfor %}
</div>
{% if companies_page.pages > 1 %}
<div class="card-footer"><nav><ul class="pagination pagination-sm mb-0 justify-content-center">
{% for p in companies_page.iter_pages() %}{% if p %}<li class="page-item {% if p == companies_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{ url_for('main.admin_companies', page=p, q=q, company_id=selected_company.id if selected_company else none) }}">{{ p }}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}
</ul></nav></div>{% endif %}
</div>
</div>
<div class="col-xl-8 col-xxl-9">
{% if selected_company %}
<div class="card mb-4"><div class="card-header d-flex justify-content-between align-items-center"><div><strong>{{ selected_company.name }}</strong><div class="small text-body-secondary">Ustawienia firmy</div></div><span class="badge text-bg-secondary">ID {{ selected_company.id }}</span></div>
<div class="card-body">
<form action="/api/companies/{{ selected_company.id }}" data-method="PUT" class="ajax-form row g-3">
<div class="col-lg-5"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" value="{{ selected_company.name }}" required></div>
<div class="col-md-4 col-lg-3"><label class="form-label">Forma prawna</label><select class="form-select" name="entity_type"><option {% if selected_company.entity_type=='JDG' %}selected{% endif %}>JDG</option><option {% if selected_company.entity_type=='Sp. z o.o.' %}selected{% endif %}>Sp. z o.o.</option><option {% if selected_company.entity_type=='Inna' %}selected{% endif %}>Inna</option></select></div>
<div class="col-md-8 col-lg-4"><label class="form-label">Województwo</label><select class="form-select" name="region">{% for r in regions %}<option value="{{r}}" {% if selected_company.region==r %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label">Stawka VAT (%)</label><input class="form-control" type="number" min="0" step="0.01" name="vat_rate" value="{{ selected_company.vat_rate }}"></div>
<div class="col-md-4"><label class="form-label">Odliczenie VAT (%)</label><input class="form-control" type="number" min="0" max="100" step="0.01" name="vat_deduction_percent" value="{{ selected_company.vat_deduction_percent }}"></div>
<div class="col-md-4"><label class="form-label">Podział faktury po dniu</label><input class="form-control" type="number" min="1" max="28" name="invoice_split_day" value="{{ selected_company.invoice_split_day }}"></div>
<div class="col-12 d-flex justify-content-between align-items-center"><div class="form-check"><input class="form-check-input" name="active" type="checkbox" id="company-active" {% if selected_company.active %}checked{% endif %}><label class="form-check-label" for="company-active">Firma aktywna</label></div><button class="btn btn-primary">Zapisz ustawienia</button></div>
</form>
</div>
</div>
<div class="card mb-4"><div class="card-header"><strong>Karty paliwowe</strong></div><div class="card-body">
<form action="/api/fuel-cards" method="post" class="ajax-form row g-3 mb-4"><input type="hidden" name="company_id" value="{{selected_company.id}}"><div class="col-md-4"><label class="form-label">Nazwa karty</label><input class="form-control" name="name" placeholder="Np. UTA flota główna" required></div><div class="col-md-3"><label class="form-label">Operator karty</label><input class="form-control" name="provider" placeholder="UTA, DKV, Orlen" required></div><div class="col-md-3"><label class="form-label">Opis</label><input class="form-control" name="description" placeholder="Opcjonalnie"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100">Dodaj kartę</button></div></form>
<div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Karta</th><th>Operator karty</th><th>Opis</th><th>Status</th></tr></thead><tbody>{% for card in fuel_cards %}<tr><td><strong>{{card.name}}</strong></td><td>{{card.provider}}</td><td>{{card.description or '—'}}</td><td><span class="badge {% if card.active %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{'aktywna' if card.active else 'wyłączona'}}</span></td></tr>{% else %}<tr><td colspan="4" class="text-center text-body-secondary py-4">Brak kart paliwowych.</td></tr>{% endfor %}</tbody></table></div>
</div></div>
<div class="card"><div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3"><div><h2 class="h5 mb-1">Ulubione stacje firmy</h2><p class="text-body-secondary mb-0">Do 25 stacji. Pusta lista oznacza 10 największych sieci w Polsce.</p></div><button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#companyFavoritesModal">Edytuj ulubione</button></div></div>
<div class="modal fade" id="companyFavoritesModal" tabindex="-1"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><form action="/api/companies/{{selected_company.id}}/favorite-stations" data-method="PUT" class="ajax-form favorite-station-form" data-max-favorites="25"><div class="modal-header"><h2 class="modal-title fs-5">Ulubione stacje: {{selected_company.name}}</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><input class="form-control mb-3 station-modal-search" placeholder="Szukaj po nazwie sieci, firmy lub punktu"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th></th><th>Nazwa</th><th class="text-end">Punkty</th></tr></thead><tbody>{% for s in stations %}<tr data-station-name="{{(s.display_name ~ ' ' ~ s.company_name ~ ' ' ~ (s.station_name_list|join(' ')))|lower}}"><td><input class="form-check-input" type="checkbox" name="station_ids" value="{{s.id}}" {% if s in selected_company.favorite_stations %}checked{% endif %}></td><td><strong>{{s.display_name}}</strong>{% if s.company_name != s.display_name %}<div class="small text-body-secondary">{{s.company_name}}</div>{% endif %}</td><td class="text-end">{{s.station_count}}</td></tr>{% endfor %}</tbody></table></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>
{% else %}<div class="card"><div class="card-body text-center text-body-secondary py-5">Dodaj lub wybierz firmę.</div></div>{% endif %}
</div>
</div>
{% if current_user.role == 'admin' %}<div class="modal fade" id="createCompanyModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content"><form action="/api/companies" method="post" class="ajax-form"><div class="modal-header"><h2 class="modal-title fs-5">Dodaj firmę</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" required></div><div class="row g-3"><div class="col-md-6"><label class="form-label">Forma prawna</label><select class="form-select" name="entity_type"><option>JDG</option><option>Sp. z o.o.</option><option>Inna</option></select></div><div class="col-md-6"><label class="form-label">Województwo</label><select class="form-select" name="region">{% for r in regions %}<option value="{{r}}">{{r}}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Stawka VAT (%)</label><input class="form-control" type="number" name="vat_rate" value="23" step="0.01"></div><div class="col-md-6"><label class="form-label">Odliczenie VAT (%)</label><input class="form-control" type="number" name="vat_deduction_percent" value="50" step="0.01"></div></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Dodaj firmę</button></div></form></div></div></div>{% endif %}
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block title %}Użytkownicy{% endblock %}{% block content %}
<h1 class="h2 mb-3">Administracja</h1>{% include '_admin_nav.html' %}
<div class="card mb-4"><div class="card-body"><h2 class="h5">Dodaj użytkownika</h2><form action="/api/users" method="post" class="ajax-form row g-3"><div class="col-md-3"><input class="form-control" name="user_name" placeholder="Imię i nazwisko" required></div><div class="col-md-3"><input class="form-control" type="email" name="email" placeholder="E-mail" required></div><div class="col-md-2"><input class="form-control" type="password" name="password" minlength="8" placeholder="Hasło" required></div><div class="col-md-2"><select class="form-select" name="company_id">{% for c in companies %}<option value="{{c.id}}">{{c.name}}</option>{% endfor %}</select></div><div class="col-md-2"><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option>{% if current_user.role=='admin' %}<option value="admin">Admin</option>{% endif %}</select></div><div class="col-12"><button class="btn btn-primary">Dodaj użytkownika</button></div></form></div></div>
<div class="card"><div class="card-body"><div class="d-flex justify-content-between gap-3 mb-3"><h2 class="h5 mb-0">Użytkownicy</h2><form class="d-flex ajax-nav-form" method="get"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj"><button class="btn btn-secondary ms-2">Szukaj</button></form></div><div class="table-responsive"><table class="table table-hover"><thead><tr><th>Nazwa</th><th>Firma</th><th>Rola</th><th>Karta</th><th></th></tr></thead><tbody>{% for u in users_page.items %}<tr><td>{{u.name}}<div class="small text-body-secondary">{{u.email}}</div></td><td>{{u.company.name if u.company else '—'}}</td><td>{{u.role}}</td><td>{{u.fuel_card.name if u.fuel_card else '—'}}</td><td class="text-end"><button class="btn btn-primary btn-sm user-edit-btn" data-bs-toggle="modal" data-bs-target="#userEditModal" data-id="{{u.id}}" data-name="{{u.name|e}}" data-email="{{u.email|e}}" data-role="{{u.role}}" data-active="{{1 if u.active else 0}}" data-card="{{u.fuel_card_id or ''}}" data-company="{{u.company_id or ''}}">Edytuj</button></td></tr>{% endfor %}</tbody></table></div></div></div>
<div class="modal fade" id="userEditModal"><div class="modal-dialog"><div class="modal-content"><form id="user-edit-form" class="ajax-form" data-method="PUT"><div class="modal-header"><h2 class="modal-title fs-5">Edytuj użytkownika</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body row g-3"><div class="col-12"><label class="form-label">Nazwa</label><input class="form-control" name="name" required></div><div class="col-12"><label class="form-label">E-mail</label><input class="form-control" name="email" type="email" required></div><div class="col-md-6"><label class="form-label">Firma</label><select class="form-select" name="company_id">{% for c in companies %}<option value="{{c.id}}">{{c.name}}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Rola</label><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option>{% if current_user.role=='admin' %}<option value="admin">Admin</option>{% endif %}</select></div><div class="col-12"><label class="form-label">Karta paliwowa</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards %}<option value="{{card.id}}">{{card.name}}</option>{% endfor %}</select></div><div class="col-12"><label class="form-label">Nowe hasło</label><input class="form-control" type="password" name="password" minlength="8"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="active" id="modal-active"><label class="form-check-label" for="modal-active">Aktywny</label></div></div><div class="modal-footer"><button class="btn btn-danger me-auto" id="user-delete-btn" type="button">Usuń użytkownika</button><button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="pl" data-bs-theme="{{ app_theme.mode }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}FuelTrack{% endblock %}</title>
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/charts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/choices.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top"><div class="container"><a class="navbar-brand fw-bold text-body-emphasis" href="{{ url_for('main.dashboard') }}">FuelTrack</a>{% if current_user.is_authenticated %}<div class="d-flex gap-2 align-items-center flex-wrap"><a class="btn btn-sm btn-secondary" href="{{ url_for('main.dashboard') }}">Podsumowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.fuel') }}">Tankowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.vehicles') }}">Pojazdy</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>{% if current_user.role in ['boss','admin'] %}<a class="btn btn-sm btn-secondary" href="{{ url_for('main.stations') }}">Stacje</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.admin_companies') }}">Administracja</a>{% endif %}<a class="btn btn-sm btn-danger" href="/api/auth/logout" data-api-logout>Wyloguj</a></div>{% endif %}</div></nav>
<main class="container py-4">{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}<div class="alert alert-{{cat}}">{{msg}}</div>{% endfor %}{% endwith %}{% block content %}{% endblock %}</main>
<footer class="border-top mt-auto py-3"><div class="container d-flex justify-content-between align-items-center small text-body-secondary"><span>FuelTrack</span><a href="/docs" target="_blank" rel="noopener">API Docs</a></div></footer>
<script src="{{ asset_urls.bootstrap_js }}"></script>
<script src="{{ asset_urls.choices_js }}"></script>
<script src="{{ asset_urls.chart_js }}"></script>
<script src="{{ asset_urls.socket_io_js }}"></script>
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script src="{{ url_for('static', filename='js/ajax.js') }}"></script>
<script src="{{ url_for('static', filename='js/stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/orlen.js') }}"></script>
<script src="{{ url_for('static', filename='js/fuel.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script src="{{ url_for('static', filename='js/users.js') }}"></script>
<script src="{{ url_for('static', filename='js/socket.js') }}"></script>
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
{% block scripts %}{% endblock %}
</body></html>
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h2 mb-0">Podsumowanie kosztów</h1><small class="text-body-secondary">{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%</small></div><form action="{{url_for('main.dashboard')}}" class="ajax-nav-form"><input class="form-control" type="month" name="month" value="{{month}}"></form></div>
<div class="row g-3 mb-4"><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Brutto</small><strong>{{ '%.2f'|format(totals.gross) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>VAT odliczony</small><strong>{{ '%.2f'|format(totals.deductible_vat) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Koszt po VAT</small><strong>{{ '%.2f'|format(totals.final_cost) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Litry</small><strong>{{ '%.2f'|format(totals.liters) }} l</strong></div></div></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-body"><h2 class="h5">Koszt według pojazdu</h2><p class="small text-body-secondary">Cena na dystrybutorze a kwota do zapłaty według cennika karty.</p><div class="chart-box"><canvas id="cost-chart"></canvas><div id="cost-chart-empty" class="chart-empty d-none">Brak danych dla wybranego miesiąca.</div></div></div></div></div><div class="col-lg-5"><div class="card h-100"><div class="card-body"><h2 class="h5">Okresy faktur</h2><small class="text-body-secondary">{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}</small><table class="table mt-2"><thead><tr><th>Okres</th><th>Pozycje</th><th>Detal</th><th>Do zapłaty</th></tr></thead><tbody>{% for key,row in invoices.items() %}<tr><td>{{key}}</td><td>{{row.count}}</td><td>{{'%.2f'|format(row.gross)}} zł</td><td>{{'%.2f'|format(row.payable)}} zł</td></tr>{% else %}<tr><td colspan="4">Brak danych</td></tr>{% endfor %}</tbody></table></div></div></div></div>
<div class="card mt-4"><div class="card-body"><h2 class="h5">Tankowania</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Auto</th><th>Stacja</th><th>Paliwo</th><th>Litry</th><th>Cena detal.</th><th>Orlen last</th><th>Do zapłaty</th><th>Razem</th></tr></thead><tbody>{% for e in entries %}{% set s=settlements[e.id] %}<tr><td>{{e.fueled_at.strftime('%d.%m.%Y')}}</td><td>{{e.vehicle.name}}</td><td>{{e.station or '—'}}</td><td>{{e.fuel_type}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.normal_price)}} zł/l</td><td>{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}</td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">last price</span> {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l</td><td><span class="text-body-secondary text-decoration-line-through">{{'%.2f'|format(s.normal_gross)}} zł</span><br><strong>{{'%.2f'|format(s.payable_gross)}} zł</strong></td></tr>{% else %}<tr><td colspan="9">Brak tankowań w tym miesiącu.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}{% block scripts %}<script>FuelTrack.renderBarChart('cost-chart', {{by_vehicle|tojson}});</script>{% endblock %}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block title %}Logowanie FuelTrack{% endblock %}{% block content %}<div class="row justify-content-center"><div class="col-md-5 col-lg-4"><div class="card shadow-sm"><div class="card-body p-4"><h1 class="h3 mb-3">Logowanie</h1><form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on"><label class="form-label">E-mail</label><input class="form-control mb-3" type="email" name="email" autocomplete="username" required><label class="form-label">Hasło</label><input class="form-control mb-3" type="password" name="password" autocomplete="current-password" required><button class="btn btn-primary w-100">Zaloguj</button></form></div></div></div></div>{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends 'base.html' %}{% block title %}Ceny Orlen{% endblock %}{% block content %}
<div class="mb-4"><h1 class="h2 mb-1">Dane cenowe Orlen</h1><p class="text-body-secondary mb-0">Porównuj kilka paliw na jednym wykresie i zapisuj dane w bazie.</p></div>
<div class="card mb-4"><div class="card-body"><form method="get" id="orlen-filter-form" class="row g-3 align-items-end ajax-nav-form"><div class="col-lg-6"><label class="form-label d-block">Paliwa na wykresie</label><div class="d-flex flex-wrap gap-3">{% for f in fuel_types %}<label class="form-check"><input class="form-check-input" type="checkbox" name="fuel" value="{{f}}" {% if f in selected_fuels %}checked{% endif %}><span class="form-check-label">{{f}}</span></label>{% endfor %}</div></div><div class="col-lg-6"><label class="form-label d-block">Województwa LPG</label><button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#lpgRegionsModal">Wybierz województwa</button> <span id="lpg-region-count" class="small text-body-secondary">{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}</span></div><div class="col-md-2"><label class="form-label">Rok</label><input class="form-control" type="number" min="2020" max="2100" name="year" value="{{selected_year}}"></div><div class="col-md-2"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="gross" value="1" id="gross-price" {% if include_vat %}checked{% endif %}><label class="form-check-label" for="gross-price">Dolicz VAT</label></div></div><div class="col-md-2"><button class="btn btn-secondary w-100">Pokaż</button></div></form>{% if current_user.role in ['boss','admin'] %}<div class="d-flex align-items-center gap-3 mt-3"><button type="button" id="orlen-sync-btn" class="btn btn-primary">Pobierz z API</button><span id="orlen-sync-status" class="small text-body-secondary" aria-live="polite"></span></div>{% endif %}</div></div>
<div class="modal fade lpg-region-modal" id="lpgRegionsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h2 class="modal-title fs-5">Województwa LPG</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><label class="form-label" for="lpg-region-select">Wybierz jedno lub wiele województw</label><select class="form-select" name="region" id="lpg-region-select" form="orlen-filter-form" multiple>{% for r in regions %}<option value="{{r}}" {% if r in selected_regions %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div><div class="modal-footer"><button type="button" class="btn btn-primary" data-bs-dismiss="modal">Gotowe</button></div></div></div></div>
<div class="card mb-4"><div class="card-body"><h2 class="h5">Trend ceny {% if include_vat %}brutto{% else %}netto{% endif %} za litr</h2><div class="chart-wrap"><canvas id="orlen-chart"></canvas><div id="orlen-chart-empty" class="chart-empty d-none">Brak danych dla wybranych filtrów.</div></div></div></div>
<div class="card"><div class="card-body"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Paliwo</th><th>Cena netto zł/l</th><th>Region</th><th>Pobrano</th></tr></thead><tbody>{% for r in rows|reverse %}<tr><td>{{r.effective_date}}</td><td>{{r.product_name}}</td><td>{{'%.4f'|format(r.price_per_liter|float)}}</td><td>{{r.region or '—'}}</td><td>{{r.fetched_at.strftime('%Y-%m-%d %H:%M')}}</td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak danych w bazie.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}{% block scripts %}<script>FuelTrack.renderMultiLineChart('orlen-chart', {{chart|tojson}}, {{include_vat|tojson}});FuelTrack.bindOrlenSync();</script>{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Stacje paliw{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
<div><h1 class="h2 mb-1">Predefiniowane stacje paliw</h1><p class="text-body-secondary mb-0">Firmy agregowane z rejestru URE. Warunki rozliczeń ustawiasz osobno dla każdej karty paliwowej.</p></div>
<form method="post" action="/api/stations/sync" class="ajax-form"><button class="btn btn-primary">Pobierz i agreguj z URE</button></form>
</div>
{% if companies|length > 1 %}
<div class="card mb-3"><div class="card-body"><form method="get" action="{{ url_for('main.stations') }}" class="row g-2 align-items-end ajax-nav-form"><div class="col-md-6"><label class="form-label">Konfigurowana firma</label><select class="form-select" name="company_id" onchange="this.form.requestSubmit()">{% for item in companies %}<option value="{{ item.id }}" {% if item.id == company.id %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></div><input type="hidden" name="q" value="{{ q }}"><input type="hidden" name="sort" value="{{ sort }}"><input type="hidden" name="direction" value="{{ direction }}"></form></div></div>
{% endif %}
<div class="card mb-4"><div class="card-body"><form method="get" action="{{url_for('main.stations')}}" class="row g-2 ajax-nav-form" id="station-search-form"><input type="hidden" name="company_id" value="{{ company.id }}"><div class="col-lg-6"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj po nazwie stacji lub firmy" autocomplete="off" data-live-search></div><div class="col-md-3"><select class="form-select" name="sort" data-live-sort><option value="company_name" {% if sort=='company_name' %}selected{% endif %}>Nazwa</option><option value="station_count" {% if sort=='station_count' %}selected{% endif %}>Liczba stacji</option><option value="nip" {% if sort=='nip' %}selected{% endif %}>NIP</option><option value="regon" {% if sort=='regon' %}selected{% endif %}>REGON</option><option value="selected_region" {% if sort=='selected_region' %}selected{% endif %}>Województwo</option></select></div><div class="col-md-2"><select class="form-select" name="direction" data-live-sort><option value="asc" {% if direction=='asc' %}selected{% endif %}>Rosnąco</option><option value="desc" {% if direction=='desc' %}selected{% endif %}>Malejąco</option></select></div><div class="col-md-1"><button class="btn btn-secondary w-100">Szukaj</button></div></form></div></div>
<div class="card"><div class="card-body"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th><a class="link-body-emphasis ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,q=q,sort='company_name',direction='desc' if sort=='company_name' and direction=='asc' else 'asc')}}">Stacja / firma</a></th><th><a class="link-body-emphasis ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,q=q,sort='station_count',direction='asc' if sort=='station_count' and direction=='desc' else 'desc')}}">Punkty</a></th><th>Paliwa</th><th>Ustawienia</th><th>Warunki kart</th></tr></thead><tbody>
{% for s in station_page.items %}<tr>
<td>{% if s.station_count > 1 %}<button type="button" class="btn btn-link link-body-emphasis fw-semibold p-0 text-start station-points-open" data-station-id="{{s.id}}" data-station-name="{{s.display_name}}">{{s.display_name}}</button>{% else %}<strong>{{s.display_name}}</strong>{% endif %}{% if s.company_name != s.display_name %}<div class="small text-body-secondary">{{s.company_name}}</div>{% endif %}<div class="small text-body-secondary">NIP: {{s.nip or '—'}} · REGON: {{s.regon or '—'}}</div></td>
<td>{{s.station_count}}</td>
<td><div class="d-flex gap-1 flex-wrap">{% if s.has_petrol %}<span class="badge text-bg-secondary">benzyna</span>{% endif %}{% if s.has_diesel %}<span class="badge text-bg-secondary">diesel</span>{% endif %}{% if s.has_lpg %}<span class="badge text-bg-secondary">LPG</span>{% endif %}</div></td>
<td><form method="post" action="/api/stations/{{s.id}}" data-method="PUT" class="ajax-form row g-2">{% if current_user.role == 'admin' %}<div class="col-12"><label class="form-label small mb-1">Firma</label><select class="form-select form-select-sm station-inline-company-select" name="company_id" data-station-context="1">{% for item in companies %}<option value="{{item.id}}" {% if item.id == company.id %}selected{% endif %}>{{item.name}}</option>{% endfor %}</select></div>{% else %}<input type="hidden" name="company_id" value="{{ company.id }}">{% endif %}<div class="col-12"><label class="form-label small mb-1">Województwo LPG</label><select class="form-select form-select-sm" name="selected_region"><option value="">Domyślne województwo firmy</option>{% for region in s.region_list %}<option value="{{region}}" {% if s.selected_region==region %}selected{% endif %}>{{region}}</option>{% endfor %}</select></div><div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="active" id="active-{{s.id}}" {% if s.active %}checked{% endif %}><label class="form-check-label" for="active-{{s.id}}">Pokazuj w propozycjach</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="allowed-{{s.id}}" {% if s.id in allowed_ids %}checked{% endif %}><label class="form-check-label" for="allowed-{{s.id}}">Dozwolona dla firmy</label></div></div><div class="col-12 d-flex gap-2"><button class="btn btn-primary btn-sm">Zapisz</button><button type="button" class="btn btn-secondary btn-sm station-favorite-toggle" data-url="/api/companies/{{company.id}}/favorite-stations/{{s.id}}/toggle">{% if s.id in favorite_ids %}Usuń z ulubionych{% else %}Dodaj do ulubionych{% endif %}</button></div></form></td>
<td>{% for card in fuel_cards %}{% set rule=card_rules.get((card.id,s.id)) %}<form action="/api/fuel-cards/{{card.id}}/stations/{{s.id}}" data-method="PUT" class="ajax-form border rounded p-2 mb-2"><div class="fw-semibold small mb-2">{{card.name}}</div><div class="row g-2"><div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="rule-allowed-{{card.id}}-{{s.id}}" {% if not rule or rule.allowed %}checked{% endif %}><label class="form-check-label" for="rule-allowed-{{card.id}}-{{s.id}}">Dozwolona dla karty</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="use_orlen_last_price" id="rule-last-{{card.id}}-{{s.id}}" {% if rule and rule.use_orlen_last_price %}checked{% endif %}><label class="form-check-label" for="rule-last-{{card.id}}-{{s.id}}">Orlen last price</label></div></div><div class="col-6"><label class="form-label small">Rabat netto %</label><input class="form-control form-control-sm" type="number" step="0.001" name="discount_net_percent" value="{{rule.discount_net_percent if rule else 0}}"></div><div class="col-6"><label class="form-label small">Dopłata netto/l</label><input class="form-control form-control-sm" type="number" step="0.0001" name="surcharge_net_per_liter" value="{{rule.surcharge_net_per_liter if rule else 0}}"></div><div class="col-12"><button class="btn btn-primary btn-sm">Zapisz warunki</button></div></div></form>{% else %}<span class="text-body-secondary small">Najpierw dodaj kartę w ustawieniach firmy.</span>{% endfor %}</td>
</tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-5">Brak firm. Pobierz katalog z URE.</td></tr>{% endfor %}
</tbody></table></div>
{% if station_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in station_page.iter_pages() %}{% if p %}<li class="page-item {% if p==station_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,page=p,q=q,sort=sort,direction=direction)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}
</div></div>
<div class="modal fade" id="stationPointsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><h2 class="modal-title fs-5" id="stationPointsTitle">Punkty stacji</h2><div class="small text-body-secondary" id="stationPointsCount"></div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button></div><div class="modal-body"><div class="input-group mb-3"><span class="input-group-text">Szukaj</span><input class="form-control" id="stationPointsSearch" placeholder="Nazwa, ulica, miejscowość lub kod pocztowy"></div><div id="stationPointsLoading" class="text-center py-5 d-none"><div class="spinner-border" role="status"></div></div><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Nazwa punktu</th><th>Adres</th><th>Województwo</th><th>Paliwa</th></tr></thead><tbody id="stationPointsRows"></tbody></table></div><div id="stationPointsEmpty" class="text-center text-body-secondary py-5 d-none">Brak punktów do wyświetlenia. Wykonaj ponowny import z URE.</div></div></div></div></div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends 'base.html' %}{% block content %}
<h1 class="h2 mb-3">Pojazdy</h1>
<div class="row g-4">
<div class="col-lg-5"><div class="card"><div class="card-body"><h2 class="h5">Dodaj pojazd</h2>
<form method="post" action="/api/vehicles" class="ajax-form row g-3">
<div class="col-12"><input class="form-control" name="name" placeholder="Nazwa, np. Auto handlowca" required></div>
<div class="col-6"><input class="form-control" name="make" placeholder="Marka" required></div><div class="col-6"><input class="form-control" name="model" placeholder="Model" required></div>
<div class="col-6"><input class="form-control" name="registration" placeholder="Rejestracja" required></div><div class="col-6"><select class="form-select" name="fuel_type"><option>PB95</option><option>PB98</option><option>DIESEL</option><option>LPG</option></select></div>
<div class="col-6"><input class="form-control" type="number" name="current_odometer" placeholder="Licznik"></div>{% if current_user.role in ['boss','admin'] %}<div class="col-12"><label class="form-label">Właściciel / użytkownik pojazdu</label><select class="form-select" name="owner_id">{% for u in assignable_users %}<option value="{{u.id}}" {% if u.id==current_user.id %}selected{% endif %}>{{u.name}} · {{u.company.name if u.company else 'bez firmy'}}</option>{% endfor %}</select></div>{% endif %}<div class="col-6"><select class="form-select" name="fuel_card_id"><option value="">Bez przypisanej karty</option>{% for card in fuel_cards %}<option value="{{card.id}}">{{card.name}} · {{card.provider}}</option>{% endfor %}</select></div>
<div class="col-12"><button class="btn btn-primary">Dodaj</button></div></form></div></div></div>
<div class="col-lg-7">
{% for v in vehicles %}<div class="card mb-3"><div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2"><div><h2 class="h5 mb-1">{{v.name}}</h2><div>{{v.make}} {{v.model}} · {{v.registration}}</div><small class="text-body-secondary">{{v.fuel_type}} · {{v.current_odometer}} km{% if v.fuel_card %} · karta {{v.fuel_card.name}}{% endif %}</small></div><span class="badge text-bg-secondary flex-shrink-0">Właściciel: {{v.owner.name}}</span></div>
{% if current_user.role in ['boss','admin'] or v.owner_id==current_user.id %}
<div class="mt-3"><h3 class="h6">Udostępnienia</h3><div class="d-flex flex-wrap gap-2 mb-2">{% for driver in v.drivers %}<form method="post" action="/api/vehicles/{{v.id}}/shares/{{driver.id}}" data-method="DELETE" class="ajax-form"><button class="btn btn-sm btn-secondary" title="Odbierz dostęp">{{driver.name}} ×</button></form>{% else %}<span class="text-body-secondary small">Nikomu nie udostępniono.</span>{% endfor %}</div>
<form method="post" action="/api/vehicles/{{v.id}}/shares" class="input-group ajax-form"><input class="form-control" type="email" name="email" placeholder="E-mail użytkownika" required><button class="btn btn-secondary">Udostępnij</button></form></div><form action="/api/vehicles/{{v.id}}/fuel-card" data-method="PUT" class="ajax-form row g-2 mt-2"><div class="col-md-8"><label class="form-label">Karta paliwowa pojazdu</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards %}<option value="{{card.id}}" {% if v.fuel_card_id==card.id %}selected{% endif %}>{{card.name}} · {{card.provider}}</option>{% endfor %}</select></div><div class="col-md-4 d-flex align-items-end"><button class="btn btn-primary w-100">Zapisz kartę</button></div></form>
{% endif %}</div></div>{% else %}<div class="alert alert-info">Brak pojazdów.</div>{% endfor %}</div></div>
{% endblock %}