diff --git a/app/api/fuel_entries.py b/app/api/fuel_entries.py index ebd508a..679651f 100644 --- a/app/api/fuel_entries.py +++ b/app/api/fuel_entries.py @@ -16,6 +16,7 @@ from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, Fuel from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period from ..station_catalog import sync_station_catalog from ..vat import freeze_vat_action +from ..settlement import freeze as freeze_settlement FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") @@ -44,7 +45,8 @@ def create_fuel_entry(): 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) + e=FuelEntry(vehicle=vehicle,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) freeze_vat_action(e, settings.vat_rate) + freeze_settlement(e, settings, source="Automatycznie przy dodaniu przez API") vehicle.current_odometer=odo;db.session.add(e);db.session.commit();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) diff --git a/app/database.py b/app/database.py index fdba4b5..dbf8503 100644 --- a/app/database.py +++ b/app/database.py @@ -23,7 +23,16 @@ def _startup_lock(app: Flask): def prepare_database(app: Flask) -> list[str]: with app.app_context(), _startup_lock(app): initialize_database() - return migrate_database() + applied = migrate_database() + from .extensions import db + from .models import FuelEntry + from .settlement import freeze + pending = FuelEntry.query.filter_by(settlement_frozen=False).all() + for entry in pending: + freeze(entry, source="Migracja istniejącego wpisu", migration_note="Warunki odtworzono z ustawień dostępnych podczas migracji; wymagają weryfikacji.") + if pending: + db.session.commit() + return applied __all__ = ["initialize_database", "migrate_database", "prepare_database"] diff --git a/app/database_migrations.py b/app/database_migrations.py index 33c4276..2e0bd14 100644 --- a/app/database_migrations.py +++ b/app/database_migrations.py @@ -66,9 +66,33 @@ def add_fuel_entry_vat_action_snapshot() -> None: db.session.execute(text("UPDATE fuel_entry SET vat_action_frozen = 1 WHERE vat_action_frozen = 0")) + +def add_fuel_entry_full_settlement_snapshot() -> None: + inspector = inspect(db.engine) + columns = {column["name"] for column in inspector.get_columns("fuel_entry")} + additions = { + "settlement_frozen": "BOOLEAN NOT NULL DEFAULT 0", + "snapshot_vat_rate": "NUMERIC(6, 3)", "snapshot_vat_deduction_percent": "NUMERIC(6, 3)", + "snapshot_uses_last_price": "BOOLEAN NOT NULL DEFAULT 0", "snapshot_discount_percent": "NUMERIC(6, 3)", + "snapshot_surcharge_per_liter": "NUMERIC(10, 4)", "snapshot_rule_source": "VARCHAR(160)", + "snapshot_base_net_price": "NUMERIC(12, 6)", "snapshot_settlement_net_price": "NUMERIC(12, 6)", + "result_retail_net_price": "NUMERIC(12, 6)", "result_invoice_price": "NUMERIC(12, 6)", + "result_effective_price": "NUMERIC(12, 6)", "result_retail_gross": "NUMERIC(14, 4)", + "result_retail_net_total": "NUMERIC(14, 4)", "result_settlement_net_total": "NUMERIC(14, 4)", + "result_invoice_gross": "NUMERIC(14, 4)", "result_deductible_vat": "NUMERIC(14, 4)", + "result_effective_cost": "NUMERIC(14, 4)", "result_saving_net": "NUMERIC(14, 4)", + "result_gross_saving": "NUMERIC(14, 4)", "settlement_snapshot_source": "VARCHAR(120)", + "settlement_migration_note": "VARCHAR(255)" + } + for name, definition in additions.items(): + if name not in columns: + db.session.execute(text(f"ALTER TABLE fuel_entry ADD COLUMN {name} {definition}")) + + MIGRATIONS: tuple[Migration, ...] = ( Migration("20260713_company_station_access_mode", add_station_access_mode), Migration("20260719_fuel_entry_vat_action_snapshot", add_fuel_entry_vat_action_snapshot), + Migration("20260720_fuel_entry_full_settlement_snapshot", add_fuel_entry_full_settlement_snapshot), ) def ensure_migration_table() -> None: diff --git a/app/main.py b/app/main.py index 425543d..68bcff0 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, inv from .station_catalog import sync_station_catalog from . import THEMES from .vat import entry_vat_rate, freeze_vat_action, global_vat_override, save_global_vat_override -from .domain.costs import calculate_net_price_costs, calculate_net_price_comparison +from .settlement import calculate as calculate_snapshot_settlement, freeze as freeze_settlement main = Blueprint("main", __name__) FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") @@ -101,87 +101,75 @@ def parse_fuel_entry_form(entry): entry.wholesale_price = Decimal(wholesale_price) if wholesale_price else None entry.wholesale_source = (request.form.get("wholesale_source") or "").strip() or None settings = vehicle.company or (vehicle.owner.company if vehicle.owner else None) or CompanySettings.query.first() - freeze_vat_action(entry, settings.vat_rate, previous_fueled_at) + + # During manual editing the action snapshot is authoritative. This prevents + # later global actions from changing historical settlements. + if "vat_action_applied" in request.form or any( + key in request.form for key in ("vat_action_name", "vat_action_rate", "vat_action_start", "vat_action_end") + ): + applied = "vat_action_applied" in request.form + entry.vat_action_frozen = True + entry.vat_action_applied = applied + if applied: + action_name = (request.form.get("vat_action_name") or "").strip() + action_rate_raw = (request.form.get("vat_action_rate") or "").strip() + if not action_name: + raise ValueError("Podaj nazwę akcji") + if not action_rate_raw: + raise ValueError("Podaj stawkę VAT akcji") + action_rate = Decimal(action_rate_raw) + if action_rate < 0 or action_rate > 100: + raise ValueError("Stawka VAT akcji musi mieścić się w zakresie 0–100%") + start_raw = (request.form.get("vat_action_start") or "").strip() + end_raw = (request.form.get("vat_action_end") or "").strip() + action_start = date.fromisoformat(start_raw) if start_raw else None + action_end = date.fromisoformat(end_raw) if end_raw else None + if action_start and action_end and action_start > action_end: + raise ValueError("Data zakończenia akcji nie może być wcześniejsza niż data rozpoczęcia") + fuel_day = fueled_at.date() + if action_start and fuel_day < action_start: + raise ValueError("Data tankowania jest wcześniejsza niż początek akcji") + if action_end and fuel_day > action_end: + raise ValueError("Data tankowania jest późniejsza niż koniec akcji") + entry.vat_action_name = action_name + entry.vat_action_rate = action_rate + entry.vat_action_start = action_start + entry.vat_action_end = action_end + else: + entry.vat_action_name = None + entry.vat_action_rate = None + entry.vat_action_start = None + entry.vat_action_end = None + else: + freeze_vat_action(entry, settings.vat_rate, previous_fueled_at) + + manual_keys = ("snapshot_vat_rate", "snapshot_vat_deduction_percent", "snapshot_discount_percent", "snapshot_surcharge_per_liter", "snapshot_uses_last_price") + if any(key in request.form for key in manual_keys): + vat_rate = Decimal(request.form.get("snapshot_vat_rate", "")) + deduction = Decimal(request.form.get("snapshot_vat_deduction_percent", "")) + discount = Decimal(request.form.get("snapshot_discount_percent", "0") or 0) + surcharge = Decimal(request.form.get("snapshot_surcharge_per_liter", "0") or 0) + if not (0 <= vat_rate <= 100 and 0 <= deduction <= 100 and 0 <= discount <= 100): + raise ValueError("Stawki VAT, odliczenia i rabatu muszą mieścić się w zakresie 0–100%") + if entry.vat_action_applied and entry.vat_action_rate is not None and vat_rate != Decimal(entry.vat_action_rate): + raise ValueError("Stawka VAT rozliczenia musi być zgodna ze stawką zapisanej akcji") + uses_last = "snapshot_uses_last_price" in request.form + if uses_last and entry.wholesale_price is None: + raise ValueError("Last Price wymaga zapisanej ceny hurtowej") + conditions = {"vat_rate": vat_rate, "vat_deduction_percent": deduction, "uses_last_price": uses_last, + "discount_percent": discount, "surcharge_per_liter": surcharge, + "rule_source": (request.form.get("snapshot_rule_source") or "Ręczna korekta").strip()} + freeze_settlement(entry, settings, conditions, source="Ręczna korekta w edycji") + else: + freeze_settlement(entry, settings, source="Automatycznie przy zapisie/edycji") return vehicle def calculate_entry_settlement(entry, fallback_settings=None): - """Calculate retail, settlement and company costs for one fuel entry. - - Commercial saving is always calculated net-to-net: pump gross price is first - converted to net and then compared with Last Price net. VAT is reported - separately and never counted as a Last Price saving. - """ - entry_settings = entry.vehicle.company or (entry.vehicle.owner.company if entry.vehicle and entry.vehicle.owner else None) or fallback_settings or CompanySettings.query.first() - vat_rate = entry_vat_rate(entry, entry_settings.vat_rate) - vat_deduction_percent = entry_settings.vat_deduction_percent - retail_price = Decimal(entry.price_per_liter) - rule = FuelCardStationRule.query.filter_by( - fuel_card_id=entry.fuel_card_id, - station_company_id=entry.station_company_id, - ).first() if entry.used_fuel_card and entry.fuel_card_id and entry.station_company_id else None - policy = entry.vehicle.fuel_card_policy if entry.used_fuel_card else None - uses_last_price = bool( - entry.wholesale_price is not None and ( - (rule and rule.use_orlen_last_price) or (not rule and policy and policy.use_orlen_last_price) - ) - ) - - if uses_last_price: - settlement_net = Decimal(entry.wholesale_price) - else: - settlement_net = retail_price / (Decimal("1") + Decimal(str(vat_rate)) / Decimal("100")) - - # A station-specific card rule has priority. The vehicle policy is a safe - # fallback for cards/stations without a dedicated rule. - if rule: - discount_percent = Decimal(rule.discount_net_percent or 0) - surcharge_per_liter = Decimal(rule.surcharge_net_per_liter or 0) - elif policy: - discount_percent = Decimal(policy.discount_percent or 0) - surcharge_per_liter = Decimal(policy.surcharge_per_liter or 0) - else: - discount_percent = Decimal("0") - surcharge_per_liter = Decimal("0") - discount_percent = min(max(discount_percent, Decimal("0")), Decimal("100")) - settlement_net *= Decimal("1") - discount_percent / Decimal("100") - settlement_net += surcharge_per_liter - settlement_net = max(settlement_net, Decimal("0")) - - price_costs = calculate_net_price_costs(settlement_net, vat_rate, vat_deduction_percent) - liters = Decimal(entry.liters) - comparison = calculate_net_price_comparison(retail_price, settlement_net, liters, vat_rate) - retail_gross = retail_price * liters - invoice_gross = price_costs["invoice_gross"] * liters - deductible_vat = price_costs["deductible_vat"] * liters - effective_cost = price_costs["effective_cost"] * liters - saving_net = comparison["saving_net_total"] if uses_last_price else Decimal("0") - saving_net_per_liter = comparison["saving_net_per_liter"] if uses_last_price else Decimal("0") - gross_saving = max(retail_gross - invoice_gross, Decimal("0")) - gross_saving_per_liter = max(retail_price - price_costs["invoice_gross"], Decimal("0")) - return { - "liters": liters, - "retail_price": retail_price, - "retail_net_price": comparison["retail_net_price"], - "settlement_net": settlement_net, - "invoice_price": price_costs["invoice_gross"], - "effective_price": price_costs["effective_cost"], - "retail_gross": retail_gross, - "retail_net_total": comparison["retail_net_total"], - "settlement_net_total": comparison["settlement_net_total"], - "invoice_gross": invoice_gross, - "deductible_vat": deductible_vat, - "effective_cost": effective_cost, - "saving_net": saving_net, - "saving_net_per_liter": saving_net_per_liter, - "gross_saving": gross_saving, - "gross_saving_per_liter": gross_saving_per_liter, - "discount_percent": discount_percent, - "surcharge_per_liter": surcharge_per_liter, - "uses_last_price": uses_last_price, - "vat_rate": Decimal(str(vat_rate)), - "vat_deduction_percent": Decimal(str(vat_deduction_percent)), - } + # Historyczne wyniki po zamrożeniu korzystają wyłącznie z migawki wpisu. + if not entry.settlement_frozen: + freeze_settlement(entry, fallback_settings, source="Migracja istniejącego wpisu", migration_note="Warunki odtworzono z ustawień dostępnych podczas migracji; wymagają weryfikacji.") + return calculate_snapshot_settlement(entry, fallback_settings) def accessible_vehicles(): if current_user.role == "admin": @@ -320,8 +308,9 @@ def fuel(): 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) + e = FuelEntry(vehicle=vehicle, 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) freeze_vat_action(e, settings.vat_rate) + freeze_settlement(e, settings, source="Automatycznie przy dodaniu") vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard")) allowed=list(settings.allowed_stations) company_favorites=list(settings.favorite_stations) diff --git a/app/models.py b/app/models.py index c4b2bf0..953807f 100644 --- a/app/models.py +++ b/app/models.py @@ -177,6 +177,28 @@ class FuelEntry(db.Model): vat_action_rate = db.Column(db.Numeric(6, 3)) vat_action_start = db.Column(db.Date) vat_action_end = db.Column(db.Date) + settlement_frozen = db.Column(db.Boolean, default=False, nullable=False) + snapshot_vat_rate = db.Column(db.Numeric(6, 3)) + snapshot_vat_deduction_percent = db.Column(db.Numeric(6, 3)) + snapshot_uses_last_price = db.Column(db.Boolean, default=False, nullable=False) + snapshot_discount_percent = db.Column(db.Numeric(6, 3)) + snapshot_surcharge_per_liter = db.Column(db.Numeric(10, 4)) + snapshot_rule_source = db.Column(db.String(160)) + snapshot_base_net_price = db.Column(db.Numeric(12, 6)) + snapshot_settlement_net_price = db.Column(db.Numeric(12, 6)) + result_retail_net_price = db.Column(db.Numeric(12, 6)) + result_invoice_price = db.Column(db.Numeric(12, 6)) + result_effective_price = db.Column(db.Numeric(12, 6)) + result_retail_gross = db.Column(db.Numeric(14, 4)) + result_retail_net_total = db.Column(db.Numeric(14, 4)) + result_settlement_net_total = db.Column(db.Numeric(14, 4)) + result_invoice_gross = db.Column(db.Numeric(14, 4)) + result_deductible_vat = db.Column(db.Numeric(14, 4)) + result_effective_cost = db.Column(db.Numeric(14, 4)) + result_saving_net = db.Column(db.Numeric(14, 4)) + result_gross_saving = db.Column(db.Numeric(14, 4)) + settlement_snapshot_source = db.Column(db.String(120)) + settlement_migration_note = db.Column(db.String(255)) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) vehicle = db.relationship("Vehicle", back_populates="fuel_entries") user = db.relationship("User") diff --git a/app/settlement.py b/app/settlement.py new file mode 100644 index 0000000..d3c1a56 --- /dev/null +++ b/app/settlement.py @@ -0,0 +1,77 @@ +from decimal import Decimal +from .models import CompanySettings, FuelCardStationRule +from .domain.costs import calculate_net_price_costs, calculate_net_price_comparison +from .vat import entry_vat_rate + +D0 = Decimal('0') + +def _d(value, default='0'): + return Decimal(str(value if value is not None else default)) + +def live_conditions(entry, fallback_settings=None): + settings = entry.vehicle.company or (entry.vehicle.owner.company if entry.vehicle and entry.vehicle.owner else None) or fallback_settings or CompanySettings.query.first() + vat_rate = _d(entry_vat_rate(entry, settings.vat_rate)) + deduction = _d(settings.vat_deduction_percent) + rule = FuelCardStationRule.query.filter_by(fuel_card_id=entry.fuel_card_id, station_company_id=entry.station_company_id).first() if entry.used_fuel_card and entry.fuel_card_id and entry.station_company_id else None + policy = entry.vehicle.fuel_card_policy if entry.used_fuel_card else None + uses_last = bool(entry.wholesale_price is not None and ((rule and rule.use_orlen_last_price) or (not rule and policy and policy.use_orlen_last_price))) + if rule: + discount, surcharge, source = _d(rule.discount_net_percent), _d(rule.surcharge_net_per_liter), 'Reguła karty dla stacji' + elif policy: + discount, surcharge, source = _d(policy.discount_percent), _d(policy.surcharge_per_liter), 'Polityka karty pojazdu' + else: + discount, surcharge, source = D0, D0, 'Brak rabatu/dopłaty' + return {'vat_rate': vat_rate, 'vat_deduction_percent': deduction, 'uses_last_price': uses_last, + 'discount_percent': max(D0, min(discount, Decimal('100'))), 'surcharge_per_liter': surcharge, + 'rule_source': source} + +def snapshot_conditions(entry, fallback_settings=None): + if entry.settlement_frozen: + return {'vat_rate': _d(entry.snapshot_vat_rate), 'vat_deduction_percent': _d(entry.snapshot_vat_deduction_percent), + 'uses_last_price': bool(entry.snapshot_uses_last_price), 'discount_percent': _d(entry.snapshot_discount_percent), + 'surcharge_per_liter': _d(entry.snapshot_surcharge_per_liter), 'rule_source': entry.snapshot_rule_source or 'Migawka'} + return live_conditions(entry, fallback_settings) + +def calculate(entry, fallback_settings=None, conditions=None): + c = conditions or snapshot_conditions(entry, fallback_settings) + vat, deduction = c['vat_rate'], c['vat_deduction_percent'] + retail, liters = _d(entry.price_per_liter), _d(entry.liters) + uses_last = bool(c['uses_last_price'] and entry.wholesale_price is not None) + base_net = _d(entry.wholesale_price) if uses_last else retail / (Decimal('1') + vat / Decimal('100')) + settlement_net = max(D0, base_net * (Decimal('1') - c['discount_percent'] / Decimal('100')) + c['surcharge_per_liter']) + costs = calculate_net_price_costs(settlement_net, vat, deduction) + comparison = calculate_net_price_comparison(retail, settlement_net, liters, vat) + retail_gross = retail * liters + invoice_gross = costs['invoice_gross'] * liters + result = { + 'liters': liters, 'retail_price': retail, 'retail_net_price': comparison['retail_net_price'], + 'base_net_price': base_net, 'settlement_net': settlement_net, 'invoice_price': costs['invoice_gross'], + 'effective_price': costs['effective_cost'], 'retail_gross': retail_gross, + 'retail_net_total': comparison['retail_net_total'], 'settlement_net_total': comparison['settlement_net_total'], + 'invoice_gross': invoice_gross, 'deductible_vat': costs['deductible_vat'] * liters, + 'effective_cost': costs['effective_cost'] * liters, + 'saving_net': comparison['saving_net_total'] if uses_last else D0, + 'saving_net_per_liter': comparison['saving_net_per_liter'] if uses_last else D0, + 'gross_saving': max(retail_gross - invoice_gross, D0), + 'gross_saving_per_liter': max(retail - costs['invoice_gross'], D0), + 'discount_percent': c['discount_percent'], 'surcharge_per_liter': c['surcharge_per_liter'], + 'uses_last_price': uses_last, 'vat_rate': vat, 'vat_deduction_percent': deduction, + 'rule_source': c.get('rule_source') or 'Migawka'} + return result + +def freeze(entry, fallback_settings=None, conditions=None, source='Automatycznie przy zapisie', migration_note=None): + c = conditions or live_conditions(entry, fallback_settings) + result = calculate(entry, fallback_settings, c) + entry.settlement_frozen = True + entry.snapshot_vat_rate = c['vat_rate']; entry.snapshot_vat_deduction_percent = c['vat_deduction_percent'] + entry.snapshot_uses_last_price = bool(c['uses_last_price']); entry.snapshot_discount_percent = c['discount_percent'] + entry.snapshot_surcharge_per_liter = c['surcharge_per_liter']; entry.snapshot_rule_source = c.get('rule_source') or source + entry.snapshot_base_net_price = result['base_net_price']; entry.snapshot_settlement_net_price = result['settlement_net'] + entry.result_retail_net_price = result['retail_net_price']; entry.result_invoice_price = result['invoice_price'] + entry.result_effective_price = result['effective_price']; entry.result_retail_gross = result['retail_gross'] + entry.result_retail_net_total = result['retail_net_total']; entry.result_settlement_net_total = result['settlement_net_total'] + entry.result_invoice_gross = result['invoice_gross']; entry.result_deductible_vat = result['deductible_vat'] + entry.result_effective_cost = result['effective_cost']; entry.result_saving_net = result['saving_net'] + entry.result_gross_saving = result['gross_saving']; entry.settlement_snapshot_source = source + entry.settlement_migration_note = migration_note + return result diff --git a/app/static/css/layout.css b/app/static/css/layout.css index 435610d..17beef9 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -7,7 +7,7 @@ html,body{min-height:100%}body{min-height:100vh;min-height:100dvh;display:flex;f .app-main{flex:1;padding-top:2rem;padding-bottom:3rem}.app-footer{padding:1.25rem 0;border-top:1px solid var(--bs-border-color-translucent);background:var(--bs-body-bg);color:var(--bs-secondary-color)}.app-footer a{color:inherit} .page-header{display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;margin-bottom:1.5rem}.page-header h1{letter-spacing:-.035em}.page-kicker{font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--bs-primary);margin-bottom:.3rem} #live-alerts{max-width:min(420px,calc(100vw - 2rem))}.app-alert{box-shadow:var(--app-shadow);border:0} -.auth-page{background:radial-gradient(circle at 20% 10%,color-mix(in srgb,var(--bs-primary) 14%,transparent),transparent 34%),radial-gradient(circle at 85% 85%,color-mix(in srgb,var(--bs-warning) 10%,transparent),transparent 30%),var(--bs-tertiary-bg)}.auth-main{display:grid;place-items:center;width:100%;padding-top:2rem;padding-bottom:2rem}.auth-shell{width:min(100%,28rem)}.auth-brand{display:block;width:max-content;margin:0 auto 1.25rem;font-size:1.8rem;font-weight:850;letter-spacing:-.055em}.auth-card{border-color:color-mix(in srgb,var(--bs-primary) 18%,var(--bs-border-color));box-shadow:0 1.25rem 3rem rgba(15,23,42,.13)} +.auth-page{background:radial-gradient(circle at 20% 10%,color-mix(in srgb,var(--bs-primary) 14%,transparent),transparent 34%),radial-gradient(circle at 85% 85%,color-mix(in srgb,var(--bs-warning) 10%,transparent),transparent 30%),var(--bs-tertiary-bg)}.auth-main{display:grid;place-items:center;width:100%;padding-top:2rem;padding-bottom:2rem}.auth-shell{width:min(100%,28rem)}.auth-brand{display:block;width:max-content;margin:0 auto 1.25rem;font-size:1.8rem;font-weight:850;letter-spacing:-.055em}.auth-card{border-color:color-mix(in srgb,var(--bs-primary) 18%,var(--bs-border-color));box-shadow:none} /* Geometria interfejsu jest wspólna dla wszystkich motywów. Motyw zmienia wyłącznie kolory. */ .app-navbar{min-height:4.25rem} diff --git a/app/static/js/fuel_entries.js b/app/static/js/fuel_entries.js index d5c8a4f..fddfff8 100644 --- a/app/static/js/fuel_entries.js +++ b/app/static/js/fuel_entries.js @@ -1 +1 @@ -(()=>{const F=FuelTrack;F.bindFuelEntryModal=()=>{const form=F.qs('#fuel-entry-edit-form'),del=F.qs('#fuel-entry-delete-btn');if(!form)return;F.qsa('.fuel-entry-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{const d=JSON.parse(btn.dataset.entry);form.action=`/api/fuel-entries/${d.id}`;form.dataset.entryId=d.id;['vehicle_id','fueled_at','fuel_type','liters','price_per_liter','odometer','station_company_id','station','invoice_number','fuel_card_id','wholesale_price','wholesale_source'].forEach(k=>{if(form.elements[k])form.elements[k].value=d[k]??''});form.elements.used_fuel_card.checked=Boolean(d.used_fuel_card)})});if(del&&!del.dataset.bound){del.dataset.bound='1';del.addEventListener('click',async()=>{const id=form.dataset.entryId;if(!id||!confirm('Usunąć to tankowanie? Tej operacji nie można cofnąć.'))return;const old=del.innerHTML;del.disabled=true;del.innerHTML='Usuwanie…';try{const r=await fetch(`/api/fuel-entries/${id}`,{method:'DELETE',headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Nie udało się usunąć tankowania.');F.notify(d.message);bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{del.disabled=false;del.innerHTML=old}})}}})(); +(()=>{const F=FuelTrack;F.bindFuelEntryModal=()=>{const form=F.qs('#fuel-entry-edit-form'),del=F.qs('#fuel-entry-delete-btn');if(!form)return;const actionToggle=form.elements.vat_action_applied,actionFields=['vat_action_name','vat_action_rate','vat_action_start','vat_action_end'];const syncActionFields=()=>{const enabled=Boolean(actionToggle?.checked);actionFields.forEach(k=>{if(form.elements[k])form.elements[k].disabled=!enabled})};if(actionToggle&&!actionToggle.dataset.bound){actionToggle.dataset.bound='1';actionToggle.addEventListener('change',syncActionFields)}F.qsa('.fuel-entry-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{const d=JSON.parse(btn.dataset.entry);form.action=`/api/fuel-entries/${d.id}`;form.dataset.entryId=d.id;['vehicle_id','fueled_at','fuel_type','liters','price_per_liter','odometer','station_company_id','station','invoice_number','fuel_card_id','wholesale_price','wholesale_source','vat_action_name','vat_action_rate','vat_action_start','vat_action_end','snapshot_vat_rate','snapshot_vat_deduction_percent','snapshot_discount_percent','snapshot_surcharge_per_liter','snapshot_rule_source'].forEach(k=>{if(form.elements[k])form.elements[k].value=d[k]??''});form.elements.used_fuel_card.checked=Boolean(d.used_fuel_card);if(form.elements.snapshot_uses_last_price)form.elements.snapshot_uses_last_price.checked=Boolean(d.snapshot_uses_last_price);const warning=document.getElementById('settlement-migration-warning');if(warning){warning.textContent=d.settlement_migration_note||'';warning.classList.toggle('d-none',!d.settlement_migration_note);}if(actionToggle)actionToggle.checked=Boolean(d.vat_action_applied);syncActionFields()})});if(del&&!del.dataset.bound){del.dataset.bound='1';del.addEventListener('click',async()=>{const id=form.dataset.entryId;if(!id||!confirm('Usunąć to tankowanie? Tej operacji nie można cofnąć.'))return;const old=del.innerHTML;del.disabled=true;del.innerHTML='Usuwanie…';try{const r=await fetch(`/api/fuel-entries/${id}`,{method:'DELETE',headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Nie udało się usunąć tankowania.');F.notify(d.message);bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{del.disabled=false;del.innerHTML=old}})}}})(); diff --git a/app/templates/admin.html b/app/templates/admin.html index de17245..9612b2e 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -9,7 +9,7 @@

Dodaj użytkownika

-

Ustawienia aplikacji

+

Ustawienia aplikacji

Wybrany motyw jest ustawiany globalnie dla wszystkich użytkowników aplikacji.

Karty paliwowe firmy

{% for card in fuel_cards %}
{{card.name}}
{{card.provider}}{% if card.description %} · {{card.description}}{% endif %}
{{'aktywna' if card.active else 'nieaktywna'}}
{% else %}
Brak kart paliwowych.
{% endfor %}
{% if current_user.role=='admin' %}

Użytkownicy

{{ users_page.total }} kont
{% for u in users_page.items %}{% else %}{% endfor %}
NazwaE-mailRolaStatusAkcja
{{u.name}}{{u.email}}{{u.role}}{% if u.active %}Aktywny{% else %}Nieaktywny{% endif %}
Brak użytkowników.
{% if users_page.pages > 1 %}{% endif %}
diff --git a/app/templates/admin_application.html b/app/templates/admin_application.html index 8f6413a..ce483a7 100644 --- a/app/templates/admin_application.html +++ b/app/templates/admin_application.html @@ -1,4 +1,4 @@ {% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %}

Administracja

{% include '_admin_nav.html' %} -

Wygląd aplikacji

+

Wygląd aplikacji

Wybrany motyw jest ustawiany globalnie dla wszystkich użytkowników aplikacji.
{% endblock %} diff --git a/app/templates/admin_fuel_entries.html b/app/templates/admin_fuel_entries.html index b19f53e..d6f667a 100644 --- a/app/templates/admin_fuel_entries.html +++ b/app/templates/admin_fuel_entries.html @@ -9,7 +9,7 @@
-{% for e in entries_page.items %} +{% for e in entries_page.items %} {% else %}{% endfor %}
DataPojazdUżytkownikStacjaLitryCenaLicznikAkcja
{{e.fueled_at.strftime('%d.%m.%Y %H:%M')}}{{e.vehicle.name}}
{{e.vehicle.registration}}
{{e.user.name}}{{e.station or '—'}}{{e.liters}}{{e.price_per_liter}} zł/l{{e.odometer}} km
{{e.fueled_at.strftime('%d.%m.%Y %H:%M')}}{{e.vehicle.name}}
{{e.vehicle.registration}}
{{e.user.name}}{{e.station or '—'}}{{e.liters}}{{e.price_per_liter}} zł/l{{e.odometer}} km
Brak tankowań spełniających kryteria.
{% if entries_page.pages > 1 %}{% endif %} @@ -24,5 +24,17 @@
+

Warunki są zapisywane wyłącznie dla tego tankowania i nie zmieniają ustawień globalnych.
+
+
+
+
+

Warunki rozliczenia tego tankowania

Zmiana warunków automatycznie przeliczy i zapisze wszystkie kwoty historyczne. Nie zmienia ustawień firmy ani karty.
+
+
+
+
+
+
{% endblock %}