Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok

This commit is contained in:
Kincses
2026-03-04 02:03:03 +01:00
commit 250f4f4b8f
7942 changed files with 449625 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import httpx
from bs4 import BeautifulSoup
import asyncio
# --- KONFIGURÁCIÓ ---
# Példa forrás, amit elemezni fogunk (később bővítjük)
BASE_URL = "https://www.hasznaltauto.hu/auto"
async def get_car_brands():
"""
Ez a függvény begyűjti az elérhető márkákat.
"""
print("Márkák gyűjtése folyamatban...")
# Itt szimulálunk egy kérést
# Később ide jön a tényleges scraping logika
brands = ["BMW", "Audi", "Mercedes-Benz", "Volkswagen", "Toyota", "Honda"]
return brands
async def main():
brands = await get_car_brands()
for brand in brands:
print(f"Talált márka: {brand}")
print("\nPM Üzenet: A váz készen áll a valódi adatgyűjtésre!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://code.profibot.hu/home/coder/project/backend/scrapers/vehicle_master_data.py","entries":[{"id":"dgpy.py","timestamp":1768919515883}]}

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/create_dummy_employee.py","entries":[{"id":"toEt.py","timestamp":1768944627792}]}

View File

@@ -0,0 +1,42 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def hire_driver():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("👤 Kovács János (User ID: 2) létrehozása...")
# Létrehozzuk a User-t
await conn.execute(text("""
INSERT INTO data.users (id, email, password_hash, role, country, default_currency, is_active)
VALUES (2, 'sofor@ceg.hu', 'hash123', 'PRIVATE', 'HU', 'HUF', TRUE)
ON CONFLICT (id) DO NOTHING;
"""))
# Frissítjük a sorrendet
await conn.execute(text("SELECT setval('data.users_id_seq', (SELECT MAX(id) FROM data.users));"))
print("🤝 Hozzárendelés a Te cégedhez (ID: 1)...")
# Betesszük a fleet_members táblába
await conn.execute(text("""
INSERT INTO data.fleet_members (user_id, owner_id, role)
VALUES (2, 1, 'DRIVER')
ON CONFLICT (user_id, owner_id) DO NOTHING;
"""))
print("✅ KÉSZ! Kovács János mostantól a csapatod tagja.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(hire_driver())

View File

@@ -0,0 +1,56 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date, datetime
from app.models.expense import ExpenseCategory
# --- Vehicle Schemas ---
class VehicleBase(BaseModel):
license_plate: str
make: str
model: str
year: int
fuel_type: Optional[str] = None
vin: Optional[str] = None
initial_odometer: int = 0
mot_expiry_date: Optional[date] = None
insurance_expiry_date: Optional[date] = None
class VehicleCreate(VehicleBase):
pass
class VehicleResponse(VehicleBase):
id: int
current_odometer: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# --- Event / Expense Schemas ---
class EventBase(BaseModel):
event_type: ExpenseCategory
date: date
odometer_value: int
cost_amount: int
description: Optional[str] = None
is_diy: bool = False
# Ad-Hoc Provider mező: Ha stringet kapunk, a service megkeresi vagy létrehozza
provider_name: Optional[str] = None
provider_id: Optional[int] = None # Ha már ismert ID-t küldünk
class EventCreate(EventBase):
pass
class EventResponse(EventBase):
id: int
vehicle_id: int
odometer_anomaly: bool
service_provider_id: Optional[int]
image_paths: Optional[List[str]] = []
model_config = ConfigDict(from_attributes=True)
class TCOStats(BaseModel):
vehicle_id: int
total_cost: int
breakdown: dict[str, int] # Kategóriánkénti bontás
cost_per_km: Optional[float] = 0.0

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/schemas/fleet.py","entries":[{"id":"F30V.py","timestamp":1769038110281}]}

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/models/logistics.py","entries":[{"id":"thOH.py","timestamp":1769032595982}]}

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Enum
from app.db.base import Base
import enum
# Enum definiálása
class LocationType(str, enum.Enum):
stop = "stop" # Megálló / Parkoló
warehouse = "warehouse" # Raktár
client = "client" # Ügyfél címe
class Location(Base):
__tablename__ = "locations"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
# FONTOS: Itt is megadjuk a schema="data"-t, hogy ne a public sémába akarja írni!
type = Column(Enum(LocationType, schema="data", name="location_type_enum"), nullable=False)
# Koordináták (egyelőre String, később PostGIS)
coordinates = Column(String, nullable=True)
address_full = Column(String, nullable=True)
capacity = Column(Integer, nullable=True)

View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DateTime
from sqlalchemy.sql import func
from app.db.base import Base
class Vehicle(Base):
__tablename__ = "vehicles"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False, index=True)
# Alapadatok
license_plate = Column(String, index=True, nullable=False) # Rendszám (változhat)
vin = Column(String, unique=True, index=True, nullable=True) # Alvázszám (Opcionális létrehozáskor)
make = Column(String, nullable=False) # Márka (pl. BMW)
model = Column(String, nullable=False) # Típus (pl. E46 320d)
year = Column(Integer, nullable=False) # Évjárat
fuel_type = Column(String, nullable=True) # Benzin, Dízel, EV, Hybrid
# Szervizkönyv Kritikus Adatok
initial_odometer = Column(Integer, default=0) # Kezdő km állás rögzítéskor
current_odometer = Column(Integer, default=0) # Mindig a legfrissebb ismert állás
# Lejáratok (Értesítésekhez)
mot_expiry_date = Column(Date, nullable=True) # Műszaki vizsga
insurance_expiry_date = Column(Date, nullable=True) # Kötelező biztosítás
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/models/vehicle.py","entries":[{"id":"roVG.py","timestamp":1769025580452},{"id":"6EB5.py","timestamp":1769038049778}]}

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Date
from sqlalchemy.orm import relationship
from app.db.base import Base
class VehicleModel(Base):
"""Reference table for Make/Model (e.g., Toyota Corolla)"""
__tablename__ = "vehicle_models"
__table_args__ = {"schema": "ref"}
id = Column(Integer, primary_key=True, index=True)
make = Column(String, nullable=False)
model_name = Column(String, nullable=False)
category = Column(String) # sedan, truck, van
class Vehicle(Base):
"""The physical asset"""
__tablename__ = "vehicles"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
model_id = Column(Integer, ForeignKey("ref.vehicle_models.id"))
vin = Column(String, unique=True, index=True, nullable=False)
license_plate = Column(String, unique=True, index=True, nullable=False)
production_year = Column(Integer)
status = Column(String, default="active") # active, maintenance, sold
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
history = relationship("VehicleHistory", back_populates="vehicle")
class VehicleHistory(Base):
"""
Defines who is driving what and when.
CRITICAL: This is the source of truth for 'Can User X see Vehicle Y?'
"""
__tablename__ = "vehicle_history"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
vehicle_id = Column(Integer, ForeignKey("data.vehicles.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
role = Column(String, nullable=False) # owner, driver, fleet_manager
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=True) # If NULL, the assignment is current
is_active = Column(Boolean, default=True)
vehicle = relationship("Vehicle", back_populates="history")

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
# --- JAVÍTÁS ITT ---
# Régi (hibás): from app.core.database import get_db
# Új (helyes):
from app.db.session import get_db
# -------------------
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
from app.services.social_service import SocialService
router = APIRouter()
@router.post("/", response_model=ServiceProviderResponse, status_code=status.HTTP_201_CREATED)
async def create_provider(
provider: ServiceProviderCreate,
db: AsyncSession = Depends(get_db),
user_id: int = 1
):
return await SocialService.create_service_provider(db, provider, user_id)
@router.get("/", response_model=List[ServiceProviderResponse])
async def list_providers(db: AsyncSession = Depends(get_db)):
return await SocialService.get_all_providers(db)

View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.db.session import get_db
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse, VoteCreate
from app.models.social import ServiceProvider, Vote
from app.models.user import User
# --- JAVÍTÁS: Nem osztályt, hanem függvényeket importálunk ---
from app.services.social_service import vote_for_provider, get_leaderboard
# -------------------------------------------------------------
router = APIRouter()
# ... (itt lehetnek a create_provider stb. végpontok, azokat hagyd meg) ...
@router.post("/vote", status_code=status.HTTP_200_OK)
async def cast_vote(
vote_data: VoteCreate,
db: AsyncSession = Depends(get_db),
# Itt ideális esetben a current_user-t használnánk, de most egyszerűsítünk:
# current_user: User = Depends(get_current_user)
):
# Most a teszt kedvéért feltételezzük, hogy a vote_data-ban jön a user_id is,
# vagy fix userrel tesztelünk. A Seed script úgyis közvetlenül a service-t hívja.
# Hívjuk meg a szerviz függvényt:
result = await vote_for_provider(db, vote_data.user_id, vote_data.provider_id, vote_data.vote_value)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/competitions/{competition_id}/leaderboard")
async def leaderboard(competition_id: int, db: AsyncSession = Depends(get_db)):
results = await get_leaderboard(db, competition_id)
# Formázzuk a választ
return [{"user": row.full_name, "points": row.points} for row in results]

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/api/v1/social.py","entries":[{"id":"tASB.py","timestamp":1769026181226},{"id":"70Kc.py","timestamp":1769028435827},{"id":"E63S.py","timestamp":1769035276415},{"id":"fw3n.py","timestamp":1769035874343}]}

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.db.session import get_db
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse, VoteCreate, LeaderboardEntry
from app.models.social import ServiceProvider
from app.services.social_service import vote_for_provider, get_leaderboard
# Mivel még nincs teljes Auth rendszerünk a chat kontextusban,
# egyelőre egy "mock" (szimulált) user függőséget használunk teszteléshez.
# Később ezt lecseréljük a valós `get_current_user`-re.
async def get_mock_current_user():
# Visszaadjuk a "Good Guy" user ID-ját (2) a teszthez
return 2
router = APIRouter()
@router.post("/providers", response_model=ServiceProviderResponse, status_code=status.HTTP_201_CREATED)
async def create_provider(
provider: ServiceProviderCreate,
db: AsyncSession = Depends(get_db),
user_id: int = Depends(get_mock_current_user)
):
new_provider = ServiceProvider(**provider.model_dump(), added_by_user_id=user_id)
db.add(new_provider)
await db.commit()
await db.refresh(new_provider)
return new_provider
@router.get("/providers", response_model=List[ServiceProviderResponse])
async def read_providers(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(ServiceProvider).offset(skip).limit(limit))
return result.scalars().all()
# --- ÚJ VÉGPONTOK (A Prompt alapján) ---
@router.post("/providers/{provider_id}/vote")
async def vote(
provider_id: int,
vote_data: VoteCreate,
db: AsyncSession = Depends(get_db),
user_id: int = Depends(get_mock_current_user)
):
"""Szavazás egy szolgáltatóra. A rendszer automatikusan kezeli a hírnevet és pontokat."""
if vote_data.vote_value not in [1, -1]:
raise HTTPException(status_code=400, detail="Vote value must be 1 or -1")
result = await vote_for_provider(db, user_id, provider_id, vote_data.vote_value)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/competitions/{competition_id}/leaderboard", response_model=List[LeaderboardEntry])
async def get_competition_leaderboard(competition_id: int, db: AsyncSession = Depends(get_db)):
"""Ranglista lekérése az adott versenyhez."""
results = await get_leaderboard(db, competition_id)
leaderboard_data = []
for rank, (score, full_name) in enumerate(results, start=1):
# A service UserScore objektumot és nevet ad vissza, ezt alakítjuk át
leaderboard_data.append(
LeaderboardEntry(
username=full_name or "Anonymous",
points=score.points,
rank=rank
)
)
return leaderboard_data

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.core.database import get_db
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
from app.services.social_service import SocialService
router = APIRouter()
@router.post("/", response_model=ServiceProviderResponse, status_code=status.HTTP_201_CREATED)
async def create_provider(
provider: ServiceProviderCreate,
db: AsyncSession = Depends(get_db),
# TODO: Később ezt a 'current_user' dependency-ből szedjük ki
user_id: int = 1
):
"""
Új szolgáltató beküldése.
Automatikusan 'PENDING' státuszba kerül.
"""
return await SocialService.create_service_provider(db, provider, user_id)
@router.get("/", response_model=List[ServiceProviderResponse])
async def list_providers(db: AsyncSession = Depends(get_db)):
"""
Összes szolgáltató listázása (Debug célra).
"""
return await SocialService.get_all_providers(db)

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/core/email.py","entries":[{"id":"hYxe.py","timestamp":1769041483049}]}

View File

@@ -0,0 +1,33 @@
import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
from fastapi import BackgroundTasks
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
FROM_EMAIL = os.getenv("FROM_EMAIL")
async def send_email_async(subject: str, to_email: str, content: str):
message = Mail(
from_email=FROM_EMAIL,
to_emails=to_email,
subject=subject,
plain_text_content=content
)
try:
sg = SendGridAPIClient(SENDGRID_API_KEY)
sg.send(message)
except Exception as e:
print(f"--- EMAIL ERROR: {e}")
def send_verification_email(background_tasks: BackgroundTasks, email: str, token: str):
subject = "Service Finder - Fiók aktiválása"
# Itt a valós domain nevedet kell használnod
verify_url = f"http://192.168.100.43:8000/api/v1/auth/verify/{token}"
content = f"Kérjük, kattints az alábbi linkre a regisztrációd véglegesítéséhez: {verify_url}"
background_tasks.add_task(send_email_async, subject, email, content)
def send_expiry_notification(background_tasks: BackgroundTasks, email: str, doc_name: str):
subject = f"FIGYELEM: {doc_name} hamarosan lejár!"
content = f"Tisztelt Felhasználó! Értesítjük, hogy a(z) {doc_name} dokumentuma 30 napon belül lejár."
background_tasks.add_task(send_email_async, subject, email, content)

View File

@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
# 1. Aszinkron motor létrehozása
# A "future=True" biztosítja a kompatibilitást a legújabb SQLAlchemy verziókkal
engine = create_async_engine(
settings.DATABASE_URL,
echo=False, # Állítsd True-ra, ha látni akarod az SQL lekérdezéseket a logban
future=True
)
# 2. Session Factory (ez gyártja a kapcsolatokat a kérésekhez)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False
)
# 3. Base osztály (minden adatbázis modell ebből fog öröklődni)
class Base(DeclarativeBase):
pass
# 4. Dependency (Ezt használjuk majd a FastAPI végpontokban: Depends(get_db))
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
# Ha sikeres, a tranzakció commitolódik (ha explicit kérjük)
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.config import settings
# Létrehozzuk az aszinkron motort (Engine) a configban megadott URL alapján
engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True)
# Létrehozzuk a Session Factory-t (ez gyártja a kapcsolatokat)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False
)
# Ez a Dependency Injection (Függőség beszúrás) a Routerek számára
# Minden API hívásnál kapunk egy tiszta adatbázis kapcsolatot
async def get_db():
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.config import settings
# Create the Async Engine
engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True)
# Create the Session Factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False
)
# Dependency for FastAPI Routers
async def get_db():
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/db/session.py","entries":[{"id":"TEMn.py","timestamp":1769028010700},{"id":"3ZfW.py","source":"undoRedo.source","timestamp":1769028027580},{"id":"DJPN.py","timestamp":1769028101040}]}

View File

@@ -0,0 +1,180 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str
role: str
access_level: str
class IssueReport(BaseModel):
vehicle_id: int
description: str
is_critical: bool
# --- SEGÉDFÜGGVÉNY: AUDIT LOG ÍRÁSA ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
await session.execute(text("""
INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value)
VALUES (:uid, :evt, :tid, :det, :old, :new)
"""), {
"uid": user_id, "evt": event, "tid": target_id,
"det": details, "old": old_val, "new": new_val
})
# --- VÉGPONTOK ---
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
# Most már lekérjük a státuszt is!
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand,
mo.model_name, mo.category, vh.start_mileage, vh.role,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{
"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name,
"plate": r.current_plate, "category": r.category, "role": r.role,
"status": r.status, "current_issue": r.current_issue # <--- ÚJ MEZŐK
} for r in result.fetchall()]
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue -- <--- ÚJ
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
return {
"id": car.id, "brand": car.brand, "model": car.model_name,
"plate": car.current_plate, "vin": car.vin, "role": car.role,
"start_date": car.start_date, "mileage": car.start_mileage,
"currency": car.default_currency,
"status": car.status, "current_issue": car.current_issue
}
# --- ÚJ: HIBA BEJELENTÉS (LOGOLÁSSAL) ---
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
user_id = 1 # Demo User
async with AsyncSessionLocal() as session:
async with session.begin():
# 1. Frissítjük a járművet
new_status = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("""
UPDATE data.vehicles
SET status = :st, current_issue = :desc
WHERE id = :vid
"""), {"st": new_status, "desc": data.description, "vid": data.vehicle_id})
# 2. ÍRUNK A NAPLÓBA (AUDIT LOG)
await create_audit_log(
session, user_id, "ISSUE_REPORT", data.vehicle_id,
details=f"Hiba: {data.description}",
old_val="OK", new_val=new_status
)
return {"status": "success", "message": "Hiba naplózva és rögzítve."}
# --- MARADÉK (REGISZTER, FLOTTA, MEGHÍVÁS) ---
# (Ezeket most rövidítve hagyom, de a fájlban benne kell lenniük a korábbi verzió szerint)
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
ins = text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id")
r = await session.execute(ins, {"mid": data.model_id, "vin": data.vin, "plt": data.plate})
vid = r.scalar()
hist = text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)")
await session.execute(hist, {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
# LOGOLÁS ITT IS!
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Jármű regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in res.fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
token = str(uuid.uuid4())
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": token})
return {"status": "success", "message": "Meghívó elküldve!", "debug_token": token}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"):
return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,177 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel):
vehicle_id: int
description: str
is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
# ÚJ: KÖLTSÉG MODELL
class CostCreate(BaseModel):
vehicle_id: int
cost_type: str # FUEL, SERVICE, INSURANCE, TAX, OTHER
amount: float
currency: str # HUF, EUR
date: date
mileage: int
description: Optional[str] = ""
# --- LOGOLÁS ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
await session.execute(text("""
INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value)
VALUES (:uid, :evt, :tid, :det, :old, :new)
"""), {"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
# --- VÉGPONTOK ---
# 1. KÖLTSÉG HOZZÁADÁSA (Okos Km frissítéssel!)
@app.post("/api/add_cost")
async def add_cost(data: CostCreate):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
# Költség mentése
await session.execute(text("""
INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description)
VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc)
"""), {
"vid": data.vehicle_id, "uid": user_id, "type": data.cost_type,
"amt": data.amount, "curr": data.currency, "date": data.date,
"mil": data.mileage, "desc": data.description
})
# KM ÓRA AUTOMATIKUS FRISSÍTÉSE (Ha a megadott km nagyobb, mint a jelenlegi)
# Először lekérjük a jelenlegit
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": data.vehicle_id})
current = res.scalar() or 0
if data.mileage > current:
await session.execute(text("""
UPDATE data.vehicle_history SET start_mileage = :mil
WHERE vehicle_id = :vid AND end_date IS NULL
"""), {"mil": data.mileage, "vid": data.vehicle_id})
# Opcionális: Logoljuk a km frissítést is
await create_audit_log(session, user_id, "MILEAGE_UPDATE", data.vehicle_id, f"Km frissítve költség rögzítésekor: {data.mileage}")
await create_audit_log(session, user_id, "ADD_COST", data.vehicle_id, f"{data.cost_type}: {data.amount} {data.currency}")
return {"status": "success"}
# 2. SZERVIZKÖNYV LEKÉRÉSE (Történet)
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
# Lekérjük a költségeket
res_costs = await session.execute(text("""
SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, 'COST' as source
FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC
"""), {"vid": vehicle_id})
costs = res_costs.fetchall()
# Lekérjük az Audit Log eseményeket (Hiba, Javítás)
res_logs = await session.execute(text("""
SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, 'LOG' as source
FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC
"""), {"vid": vehicle_id})
logs = res_logs.fetchall()
# Összefésüljük a két listát Pythonban és dátum szerint rendezzük
combined = []
for r in costs: combined.append(dict(r._mapping))
for r in logs: combined.append(dict(r._mapping))
# Rendezés dátum szerint csökkenőbe (legújabb elöl)
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
# --- KORÁBBI VÉGPONTOK (Rövidítve a hely miatt, de ezek kellenek!) ---
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand,
mo.model_name, mo.category, vh.start_mileage, vh.role,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in result.fetchall()]
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
return {"id": car.id, "brand": car.brand, "model": car.model_name, "plate": car.current_plate, "vin": car.vin, "role": car.role, "start_date": car.start_date, "mileage": car.start_mileage, "currency": car.default_currency, "status": car.status, "current_issue": car.current_issue}
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
new_status = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": new_status, "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, user_id, "ISSUE_REPORT", data.vehicle_id, details=data.description, old_val="OK", new_val=new_status)
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, user_id, "ISSUE_RESOLVED", data.vehicle_id, details="Probléma megoldva", old_val="WARNING", new_val="OK")
return {"status": "success", "message": "Jármű státusza helyreállítva!"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,140 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator, EmailStr
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid # Token generáláshoz
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v):
if len(v) < 5: raise ValueError('Túl rövid alvázszám')
return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v):
return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v):
if v < 0: raise ValueError('Negatív km nem lehet')
return v
class InviteRequest(BaseModel):
email: EmailStr
role: str # 'DRIVER', 'FLEET_MANAGER'
access_level: str # 'FULL', 'LOG_ONLY'
# --- VÉGPONTOK ---
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("""
SELECT vm.id, m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
"""))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role} for r in result.fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
ins = text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id")
r = await session.execute(ins, {"mid": data.model_id, "vin": data.vin, "plt": data.plate})
vid = r.scalar()
hist = text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)")
await session.execute(hist, {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
return {"status": "success"}
# --- ÚJ FLOTTA VÉGPONTOK ---
# 1. Csapat lista
@app.get("/api/fleet/members")
async def get_team_members():
user_id = 1 # Demo Boss
async with AsyncSessionLocal() as session:
# Lekérjük a fleet_members táblát összekötve a users-el
query = text("""
SELECT u.email, fm.role, fm.joined_at, u.country
FROM data.fleet_members fm
JOIN data.users u ON fm.user_id = u.id
WHERE fm.owner_id = :uid
""")
result = await session.execute(query, {"uid": user_id})
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in result.fetchall()]
# 2. Meghívó küldése
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
user_id = 1
token = str(uuid.uuid4()) # Generálunk egy egyedi kódot
async with AsyncSessionLocal() as session:
async with session.begin():
# MENTÉS az invitations táblába
await session.execute(text("""
INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at)
VALUES (:email, :uid, :role, :lvl, :token, NOW() + INTERVAL '7 days')
"""), {
"email": data.email, "uid": user_id, "role": data.role,
"lvl": data.access_level, "token": token
})
# Itt küldenénk az EMAILT a valóságban
print(f"📧 EMAIL KÜLDÉSE: {data.email} -> Link: /invite/{token}")
return {"status": "success", "message": "Meghívó elküldve!", "debug_token": token}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"):
return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,167 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, validator
from typing import Optional, List, Dict
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
import shutil
from datetime import date, datetime
import uuid
import traceback
from dotenv import load_dotenv
load_dotenv()
# MAPPA & DB KONFIG
UPLOAD_DIR = "/app/frontend/uploads"
try: os.makedirs(UPLOAD_DIR, exist_ok=True)
except: pass
EXCHANGE_RATES = { "EUR_TO_HUF": 400.0, "HUF_TO_EUR": 0.0025 }
raw_url = os.getenv("DATABASE_URL")
if not raw_url: raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel): vehicle_id: int; description: str; is_critical: bool
class IssueResolve(BaseModel): vehicle_id: int
class VehicleRegister(BaseModel):
model_id: int; vin: str; plate: str; mileage: int; purchase_date: date; role: str = "OWNER"
class InviteRequest(BaseModel): email: str; role: str; access_level: str
# --- SEGÉDEK ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
try: await session.execute(text("INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value) VALUES (:uid, :evt, :tid, :det, :old, :new)"), {"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
except: pass
# --- ÚJ VÉGPONT: KÖLTSÉG TÍPUSOK LEKÉRÉSE (DB-BŐL!) ---
@app.get("/api/ref/cost_types")
async def get_cost_types():
async with AsyncSessionLocal() as session:
# Lekérjük az összes aktív típust
result = await session.execute(text("SELECT code, name, parent_code FROM ref.cost_types WHERE is_active = TRUE ORDER BY sort_order, name"))
rows = result.fetchall()
# Fát építünk a Frontendnek (Szülő -> Gyerekek)
tree = {}
# 1. lépés: Szülők
for r in rows:
if r.parent_code is None:
tree[r.code] = {"label": r.name, "subs": {}}
# 2. lépés: Gyerekek
for r in rows:
if r.parent_code and r.parent_code in tree:
tree[r.parent_code]["subs"][r.code] = r.name
return tree
# --- TÖBBI VÉGPONT (Változatlan, csak a Cost mentésnél validálhatnánk, de most egyszerűsítünk) ---
@app.post("/api/add_cost")
async def add_cost(vehicle_id: int = Form(...), cost_type: str = Form(...), amount: float = Form(...), currency: str = Form(...), mileage: int = Form(...), date_str: str = Form(...), description: str = Form(""), file: UploadFile = File(None)):
try:
user_id = 1; document_path = None
try: real_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except: return JSONResponse(status_code=400, content={"detail": "Dátum hiba"})
if file:
try:
ext = file.filename.split(".")[-1]; u_name = f"{uuid.uuid4()}.{ext}"; f_path = os.path.join(UPLOAD_DIR, u_name)
with open(f_path, "wb") as b: shutil.copyfileobj(file.file, b)
document_path = f"uploads/{u_name}"
except: pass
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description, document_url) VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc, :doc)"),
{"vid": vehicle_id, "uid": user_id, "type": cost_type, "amt": amount, "curr": currency, "date": real_date, "mil": mileage, "desc": description, "doc": document_path})
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": vehicle_id})
cur = res.scalar() or 0
if mileage > cur:
await session.execute(text("UPDATE data.vehicle_history SET start_mileage = :mil WHERE vehicle_id = :vid AND end_date IS NULL"), {"mil": mileage, "vid": vehicle_id})
await create_audit_log(session, user_id, "ADD_COST", vehicle_id, f"{cost_type}: {amount}")
return {"status": "success"}
except Exception as e: return JSONResponse(status_code=500, content={"detail": str(e)})
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""SELECT v.id, v.vin, v.current_plate, v.production_year, m.name as brand, mo.model_name, mo.category, vh.role, vh.start_date, vh.start_mileage, u.default_currency, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id JOIN data.users u ON vh.user_id = u.id WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
curr_year = date.today().year
costs = (await session.execute(text("SELECT amount, currency FROM data.costs WHERE vehicle_id = :vid AND EXTRACT(YEAR FROM date) = :year"), {"vid": vehicle_id, "year": curr_year})).fetchall()
total = 0.0
for c in costs:
if c.currency == car.default_currency: total += float(c.amount)
elif c.currency == 'EUR' and car.default_currency == 'HUF': total += float(c.amount) * EXCHANGE_RATES["EUR_TO_HUF"]
elif c.currency == 'HUF' and car.default_currency == 'EUR': total += float(c.amount) * EXCHANGE_RATES["HUF_TO_EUR"]
else: total += float(c.amount)
return {"id": car.id, "brand": car.brand, "model": car.model_name, "plate": car.current_plate, "vin": car.vin, "role": car.role, "start_date": car.start_date, "mileage": car.start_mileage, "currency": car.default_currency, "status": car.status, "current_issue": car.current_issue, "year_cost": total}
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
async with AsyncSessionLocal() as session:
costs = (await session.execute(text("SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, document_url, 'COST' as source FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC"), {"vid": vehicle_id})).fetchall()
logs = (await session.execute(text("SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, '' as document_url, 'LOG' as source FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC"), {"vid": vehicle_id})).fetchall()
combined = [dict(r._mapping) for r in costs] + [dict(r._mapping) for r in logs]
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
# --- MARADÉK (Register, Fleet stb.) ---
@app.get("/api/my_vehicles")
async def get_my_garage():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = 1 AND vh.end_date IS NULL ORDER BY vh.start_date DESC"))
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in res.fetchall()]
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": 'CRITICAL' if data.is_critical else 'WARNING', "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_REPORT", data.vehicle_id, data.description, "OK", 'CRITICAL')
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_RESOLVED", data.vehicle_id, "Megjavítva", "WARNING", "OK")
return {"status": "success"}
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in (await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))).fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid: vid = (await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": data.model_id, "vin": data.vin, "plt": data.plate})).scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)"), {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in (await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))).fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": str(uuid.uuid4())})
return {"status": "success"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,230 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
import shutil
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
# --- KONFIGURÁCIÓ ---
# Itt adjuk meg, hova mentsen. Később ezt a mappát csatoljuk a NAS-hoz!
UPLOAD_DIR = "/app/frontend/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Demo Árfolyamok (Később API-ból jön)
EXCHANGE_RATES = {
"EUR_TO_HUF": 400.0,
"HUF_TO_EUR": 0.0025
}
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel):
vehicle_id: int; description: str; is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
class VehicleRegister(BaseModel):
model_id: int; vin: str; plate: str; mileage: int; purchase_date: date; role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str; role: str; access_level: str
# --- SEGÉDFÜGGVÉNYEK ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
await session.execute(text("INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value) VALUES (:uid, :evt, :tid, :det, :old, :new)"),
{"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
# --- ÚJ VÉGPONTOK ---
# 1. KÖLTSÉG + FÁJL FELTÖLTÉS (Multipart Form)
@app.post("/api/add_cost")
async def add_cost(
vehicle_id: int = Form(...),
cost_type: str = Form(...),
amount: float = Form(...),
currency: str = Form(...),
mileage: int = Form(...),
date_str: str = Form(...), # Dátum stringként jön Formból
description: str = Form(""),
file: UploadFile = File(None) # Opcionális fájl
):
user_id = 1
document_path = None
# A) Fájl mentése (NAS Előkészítés)
if file:
file_ext = file.filename.split(".")[-1]
unique_name = f"{uuid.uuid4()}.{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# A Frontendnek relatív útvonal kell: /uploads/nev.jpg
document_path = f"uploads/{unique_name}"
async with AsyncSessionLocal() as session:
async with session.begin():
# B) Adatbázis mentés
await session.execute(text("""
INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description, document_url)
VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc, :doc)
"""), {
"vid": vehicle_id, "uid": user_id, "type": cost_type, "amt": amount,
"curr": currency, "date": date_str, "mil": mileage, "desc": description,
"doc": document_path
})
# C) Km frissítés
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": vehicle_id})
current = res.scalar() or 0
if mileage > current:
await session.execute(text("UPDATE data.vehicle_history SET start_mileage = :mil WHERE vehicle_id = :vid AND end_date IS NULL"), {"mil": mileage, "vid": vehicle_id})
await create_audit_log(session, user_id, "MILEAGE_UPDATE", vehicle_id, f"Km: {mileage}")
await create_audit_log(session, user_id, "ADD_COST", vehicle_id, f"{cost_type}: {amount} {currency} + Doc")
return {"status": "success"}
# 2. ADATLAP LEKÉRÉSE (Valutaváltással!)
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
# Alapadatok lekérése
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
# --- OKOS KÖLTSÉGSZÁMOLÁS ---
current_year = date.today().year
# Lekérjük az összes idei tételt, függetlenül a pénznemtől
q_costs = text("""
SELECT amount, currency
FROM data.costs
WHERE vehicle_id = :vid AND EXTRACT(YEAR FROM date) = :year
""")
costs_res = await session.execute(q_costs, {"vid": vehicle_id, "year": current_year})
costs = costs_res.fetchall()
total_cost = 0.0
user_curr = car.default_currency # pl. HUF
for c in costs:
if c.currency == user_curr:
total_cost += float(c.amount)
# Konverzió
elif c.currency == 'EUR' and user_curr == 'HUF':
total_cost += float(c.amount) * EXCHANGE_RATES["EUR_TO_HUF"]
elif c.currency == 'HUF' and user_curr == 'EUR':
total_cost += float(c.amount) * EXCHANGE_RATES["HUF_TO_EUR"]
else:
total_cost += float(c.amount) # Ismeretlen pénznem, hozzáadjuk simán (vagy hiba)
return {
"id": car.id, "brand": car.brand, "model": car.model_name,
"plate": car.current_plate, "vin": car.vin, "role": car.role,
"start_date": car.start_date, "mileage": car.start_mileage,
"currency": car.default_currency,
"status": car.status, "current_issue": car.current_issue,
"year_cost": total_cost # Ez már a konvertált összeg!
}
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
async with AsyncSessionLocal() as session:
# Itt is lekérjük a document_url-t!
costs = (await session.execute(text("SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, document_url, 'COST' as source FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC"), {"vid": vehicle_id})).fetchall()
logs = (await session.execute(text("SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, '' as document_url, 'LOG' as source FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC"), {"vid": vehicle_id})).fetchall()
combined = [dict(r._mapping) for r in costs] + [dict(r._mapping) for r in logs]
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
# --- MARADÉK VÉGPONTOK (Rövidítve a hely miatt) ---
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
async with AsyncSessionLocal() as session:
async with session.begin():
st = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": st, "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_REPORT", data.vehicle_id, data.description, "OK", st)
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_RESOLVED", data.vehicle_id, "Megjavítva", "WARNING", "OK")
return {"status": "success"}
@app.get("/api/my_vehicles")
async def get_my_garage():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = 1 AND vh.end_date IS NULL ORDER BY vh.start_date DESC"))
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in res.fetchall()]
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in (await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))).fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid: vid = (await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": data.model_id, "vin": data.vin, "plt": data.plate})).scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)"), {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in (await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))).fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": str(uuid.uuid4())})
return {"status": "success"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,227 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
import shutil
from datetime import date, datetime # <--- FONTOS: datetime importálva
import uuid
import traceback
from dotenv import load_dotenv
load_dotenv()
# MAPPA KONFIGURÁCIÓ
UPLOAD_DIR = "/app/frontend/uploads"
try:
os.makedirs(UPLOAD_DIR, exist_ok=True)
except Exception as e:
print(f"HIBA a mappa létrehozásakor: {e}")
EXCHANGE_RATES = { "EUR_TO_HUF": 400.0, "HUF_TO_EUR": 0.0025 }
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel):
vehicle_id: int; description: str; is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
class VehicleRegister(BaseModel):
model_id: int; vin: str; plate: str; mileage: int; purchase_date: date; role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str; role: str; access_level: str
# --- LOG HELPER ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
try:
await session.execute(text("INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value) VALUES (:uid, :evt, :tid, :det, :old, :new)"),
{"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
except Exception as e:
print(f"AUDIT LOG HIBA: {e}")
# --- API VÉGPONTOK ---
@app.post("/api/add_cost")
async def add_cost(
vehicle_id: int = Form(...),
cost_type: str = Form(...),
amount: float = Form(...),
currency: str = Form(...),
mileage: int = Form(...),
date_str: str = Form(...),
description: str = Form(""),
file: UploadFile = File(None)
):
try:
user_id = 1
document_path = None
# 1. DÁTUM KONVERTÁLÁSA (EZ VOLT A HIBA OKA!)
# A Frontend stringet küld ("2026-01-20"), mi átalakítjuk Date objektummá
try:
real_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return JSONResponse(status_code=400, content={"detail": f"Hibás dátum formátum: {date_str}"})
# 2. Fájl mentése
if file:
try:
file_ext = file.filename.split(".")[-1]
unique_name = f"{uuid.uuid4()}.{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
document_path = f"uploads/{unique_name}"
except Exception as file_error:
print(f"FÁJL MENTÉSI HIBA: {file_error}")
# Nem állunk meg, csak logoljuk, a költség attól még létrejöhet kép nélkül is
document_path = None
# 3. Adatbázis mentés
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("""
INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description, document_url)
VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc, :doc)
"""), {
"vid": vehicle_id, "uid": user_id, "type": cost_type, "amt": amount,
"curr": currency,
"date": real_date, # <--- ITT MÁR A KONVERTÁLT DÁTUMOT HASZNÁLJUK
"mil": mileage, "desc": description,
"doc": document_path
})
# Km frissítés
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": vehicle_id})
current = res.scalar() or 0
if mileage > current:
await session.execute(text("UPDATE data.vehicle_history SET start_mileage = :mil WHERE vehicle_id = :vid AND end_date IS NULL"), {"mil": mileage, "vid": vehicle_id})
await create_audit_log(session, user_id, "MILEAGE_UPDATE", vehicle_id, f"Km: {mileage}")
await create_audit_log(session, user_id, "ADD_COST", vehicle_id, f"{cost_type}: {amount} {currency}")
return {"status": "success"}
except Exception as e:
error_msg = traceback.format_exc()
print(f"KRITIKUS HIBA: {error_msg}")
return JSONResponse(status_code=500, content={"detail": f"Szerver hiba: {str(e)}"})
# --- EGYÉB VÉGPONTOK ---
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
current_year = date.today().year
q_costs = text("SELECT amount, currency FROM data.costs WHERE vehicle_id = :vid AND EXTRACT(YEAR FROM date) = :year")
costs = (await session.execute(q_costs, {"vid": vehicle_id, "year": current_year})).fetchall()
total_cost = 0.0
user_curr = car.default_currency
for c in costs:
if c.currency == user_curr: total_cost += float(c.amount)
elif c.currency == 'EUR' and user_curr == 'HUF': total_cost += float(c.amount) * EXCHANGE_RATES["EUR_TO_HUF"]
elif c.currency == 'HUF' and user_curr == 'EUR': total_cost += float(c.amount) * EXCHANGE_RATES["HUF_TO_EUR"]
else: total_cost += float(c.amount)
return {"id": car.id, "brand": car.brand, "model": car.model_name, "plate": car.current_plate, "vin": car.vin, "role": car.role, "start_date": car.start_date, "mileage": car.start_mileage, "currency": car.default_currency, "status": car.status, "current_issue": car.current_issue, "year_cost": total_cost}
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
async with AsyncSessionLocal() as session:
costs = (await session.execute(text("SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, document_url, 'COST' as source FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC"), {"vid": vehicle_id})).fetchall()
logs = (await session.execute(text("SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, '' as document_url, 'LOG' as source FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC"), {"vid": vehicle_id})).fetchall()
combined = [dict(r._mapping) for r in costs] + [dict(r._mapping) for r in logs]
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
@app.get("/api/my_vehicles")
async def get_my_garage():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = 1 AND vh.end_date IS NULL ORDER BY vh.start_date DESC"))
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in res.fetchall()]
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
async with AsyncSessionLocal() as session:
async with session.begin():
st = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": st, "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_REPORT", data.vehicle_id, data.description, "OK", st)
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_RESOLVED", data.vehicle_id, "Megjavítva", "WARNING", "OK")
return {"status": "success"}
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in (await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))).fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid: vid = (await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": data.model_id, "vin": data.vin, "plt": data.plate})).scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)"), {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in (await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))).fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": str(uuid.uuid4())})
return {"status": "success"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,94 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta
from jose import JWTError, jwt
import bcrypt # Közvetlen bcrypt használata a passlib helyett a hiba elkerülésére
import os, uuid, traceback
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
# --- JAVÍTOTT TITKOSÍTÁS (Passlib bug kikerülése) ---
def get_password_hash(password: str):
pwd_bytes = password.encode('utf-8')
# A bcrypt korlátja 72 byte, vágjuk le ha hosszabb (biztonsági best practice)
if len(pwd_bytes) > 72:
pwd_bytes = pwd_bytes[:72]
salt = bcrypt.gensalt()
return bcrypt.hashpw(pwd_bytes, salt).decode('utf-8')
def verify_password(plain_password: str, hashed_password: str):
pwd_bytes = plain_password.encode('utf-8')
if len(pwd_bytes) > 72:
pwd_bytes = pwd_bytes[:72]
return bcrypt.checkpw(pwd_bytes, hashed_password.encode('utf-8'))
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None: raise HTTPException(status_code=401)
return int(user_id)
except Exception: raise HTTPException(status_code=401)
@app.post("/api/auth/register")
async def register(email: str = Form(...), password: str = Form(...)):
try:
async with AsyncSessionLocal() as session:
async with session.begin():
hashed = get_password_hash(password)
await session.execute(
text("INSERT INTO data.users (email, password_hash) VALUES (:e, :p)"),
{"e": email, "p": hashed}
)
return {"status": "success"}
except Exception as e:
print(f"REGISZTRÁCIÓS HIBA: {str(e)}")
return JSONResponse(status_code=500, content={"detail": f"Adatbázis hiba: {str(e)}"})
@app.post("/api/auth/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": form_data.username})
user = res.fetchone()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Hibás email vagy jelszó")
token = jwt.encode({"sub": str(user.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": token, "token_type": "bearer"}
@app.get("/api/my_vehicles")
async def my_vehicles(user_id: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = :uid")
res = await session.execute(q, {"uid": user_id})
return [dict(r._mapping) for r in res.fetchall()]
@app.get("/api/ref/cost_types")
async def cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name, parent_code FROM ref.cost_types"))
rows = res.fetchall()
tree = {}
for r in rows:
if not r.parent_code: tree[r.code] = {"label": r.name, "subs": {}}
for r in rows:
if r.parent_code and r.parent_code in tree: tree[r.parent_code]["subs"][r.code] = r.name
return tree
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,76 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
import os, uuid, shutil
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
def get_password_hash(password): return pwd_context.hash(password)
def verify_password(plain, hashed): return pwd_context.verify(plain, hashed)
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None: raise HTTPException(status_code=401)
return int(user_id)
except Exception: raise HTTPException(status_code=401)
@app.post("/api/auth/register")
async def register(email: str = Form(...), password: str = Form(...)):
async with AsyncSessionLocal() as session:
async with session.begin():
hashed = get_password_hash(password)
await session.execute(text("INSERT INTO data.users (email, password_hash) VALUES (:e, :p)"), {"e": email, "p": hashed})
return {"status": "success"}
@app.post("/api/auth/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": form_data.username})
user = res.fetchone()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Hibás adatok")
token = jwt.encode({"sub": str(user.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": token, "token_type": "bearer"}
@app.get("/api/my_vehicles")
async def my_vehicles(user_id: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = :uid")
res = await session.execute(q, {"uid": user_id})
return [dict(r._mapping) for r in res.fetchall()]
@app.get("/api/ref/cost_types")
async def cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name, parent_code FROM ref.cost_types"))
rows = res.fetchall()
tree = {}
for r in rows:
if not r.parent_code: tree[r.code] = {"label": r.name, "subs": {}}
for r in rows:
if r.parent_code and r.parent_code in tree: tree[r.parent_code]["subs"][r.code] = r.name
return tree
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,191 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str
role: str
access_level: str
class IssueReport(BaseModel):
vehicle_id: int
description: str
is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
# --- LOGOLÁS ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
await session.execute(text("""
INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value)
VALUES (:uid, :evt, :tid, :det, :old, :new)
"""), {
"uid": user_id, "evt": event, "tid": target_id,
"det": details, "old": old_val, "new": new_val
})
# --- VÉGPONTOK ---
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand,
mo.model_name, mo.category, vh.start_mileage, vh.role,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{
"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name,
"plate": r.current_plate, "category": r.category, "role": r.role,
"status": r.status, "current_issue": r.current_issue
} for r in result.fetchall()]
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
return {
"id": car.id, "brand": car.brand, "model": car.model_name,
"plate": car.current_plate, "vin": car.vin, "role": car.role,
"start_date": car.start_date, "mileage": car.start_mileage,
"currency": car.default_currency,
"status": car.status, "current_issue": car.current_issue
}
# 1. HIBA BEJELENTÉS
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
new_status = 'CRITICAL' if data.is_critical else 'WARNING'
# Update Vehicle
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"),
{"st": new_status, "desc": data.description, "vid": data.vehicle_id})
# Log
await create_audit_log(session, user_id, "ISSUE_REPORT", data.vehicle_id,
details=data.description, old_val="OK", new_val=new_status)
return {"status": "success"}
# 2. HIBA JAVÍTÁS (EZ HIÁNYZOTT!)
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
# Lekérjük a régi hibát a loghoz
res = await session.execute(text("SELECT status, current_issue FROM data.vehicles WHERE id = :vid"), {"vid": data.vehicle_id})
curr = res.fetchone()
# Visszaállítás
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"),
{"vid": data.vehicle_id})
# Logolás: Ki javította meg?
await create_audit_log(session, user_id, "ISSUE_RESOLVED", data.vehicle_id,
details="Probléma megoldva", old_val=curr.status if curr else "UNKNOWN", new_val="OK")
return {"status": "success", "message": "Jármű státusza helyreállítva!"}
# --- EGYÉB VÉGPONTOK (Register, Fleet...) ---
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
ins = text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id")
r = await session.execute(ins, {"mid": data.model_id, "vin": data.vin, "plt": data.plate})
vid = r.scalar()
hist = text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)")
await session.execute(hist, {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Jármű regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in res.fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
token = str(uuid.uuid4())
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": token})
return {"status": "success", "message": "Meghívó elküldve!", "debug_token": token}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,91 @@
from fastapi import FastAPI, HTTPException, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta, date
from jose import jwt
import bcrypt, os
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
p = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(p.get("sub"))
except: raise HTTPException(status_code=401)
# --- METAADATOK ---
@app.get("/api/meta/vehicle-hierarchy")
async def get_hierarchy():
async with AsyncSessionLocal() as session:
q = text("SELECT vm.category, m.name as brand, vm.id as model_id, vm.model_name FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY vm.category, m.name")
res = await session.execute(q)
hierarchy = {}
for r in res:
if r.category not in hierarchy: hierarchy[r.category] = {}
if r.brand not in hierarchy[r.category]: hierarchy[r.category][r.brand] = []
hierarchy[r.category][r.brand].append({"id": r.model_id, "name": r.model_name})
return hierarchy
@app.get("/api/meta/cost-types")
async def get_cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name FROM ref.cost_types ORDER BY name"))
return {r.code: r.name for r in res.fetchall()}
# --- JÁRMŰ MŰVELETEK ---
@app.get("/api/my_vehicles")
async def my_vehicles(uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status,
(SELECT SUM(amount) FROM data.costs WHERE vehicle_id = v.id) as total_cost
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"uid": uid})
return [dict(r._mapping) for r in res.fetchall()]
@app.post("/api/register")
async def register_vehicle(d: dict, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": d['model_id'], "vin": d['vin'], "plt": d['plate']})
vid = res.scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, :uid, 'OWNER', CURRENT_DATE, :sm)"), {"vid": vid, "uid": uid, "sm": d['mileage']})
return {"status": "success"}
@app.delete("/api/vehicle/{vid}")
async def delete_vehicle(vid: int, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
# Soft delete: lezárjuk a history-t
await session.execute(text("UPDATE data.vehicle_history SET end_date = CURRENT_DATE WHERE vehicle_id = :vid AND user_id = :uid"), {"vid": vid, "uid": uid})
return {"status": "deleted"}
@app.post("/api/add_cost")
async def add_cost(d: dict, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date) VALUES (:vid, :uid, :type, :amt, 'HUF', CURRENT_DATE)"),
{"vid": d['vehicle_id'], "uid": uid, "type": d['type'], "amt": d['amount']})
return {"status": "success"}
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,195 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel):
vehicle_id: int
description: str
is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
class CostCreate(BaseModel):
vehicle_id: int
cost_type: str
amount: float
currency: str
date: date
mileage: int
description: Optional[str] = ""
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str
role: str
access_level: str
# --- LOG HELPER ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
await session.execute(text("INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value) VALUES (:uid, :evt, :tid, :det, :old, :new)"),
{"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
# --- VÉGPONTOK ---
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
# 1. Alapadatok
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
# 2. ÉVES KÖLTSÉG SZÁMÍTÁSA (Idei év)
# Egyszerűség kedvéért most csak a User pénznemében lévőket adjuk össze
# (Később itt lesz a valutaváltó logika)
current_year = date.today().year
q_cost = text("""
SELECT COALESCE(SUM(amount), 0)
FROM data.costs
WHERE vehicle_id = :vid
AND EXTRACT(YEAR FROM date) = :year
AND currency = :curr
""")
res_cost = await session.execute(q_cost, {"vid": vehicle_id, "year": current_year, "curr": car.default_currency})
year_cost = res_cost.scalar()
return {
"id": car.id, "brand": car.brand, "model": car.model_name,
"plate": car.current_plate, "vin": car.vin, "role": car.role,
"start_date": car.start_date, "mileage": car.start_mileage,
"currency": car.default_currency,
"status": car.status, "current_issue": car.current_issue,
"year_cost": year_cost # <--- EZT HIÁNYOLTAD!
}
# --- MARADÉK VÉGPONTOK (Cost, History, Report, Resolve, Fleet...) ---
@app.post("/api/add_cost")
async def add_cost(data: CostCreate):
user_id = 1
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description) VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc)"),
{"vid": data.vehicle_id, "uid": user_id, "type": data.cost_type, "amt": data.amount, "curr": data.currency, "date": data.date, "mil": data.mileage, "desc": data.description})
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": data.vehicle_id})
current = res.scalar() or 0
if data.mileage > current:
await session.execute(text("UPDATE data.vehicle_history SET start_mileage = :mil WHERE vehicle_id = :vid AND end_date IS NULL"), {"mil": data.mileage, "vid": data.vehicle_id})
await create_audit_log(session, user_id, "MILEAGE_UPDATE", data.vehicle_id, f"Km: {data.mileage}")
await create_audit_log(session, user_id, "ADD_COST", data.vehicle_id, f"{data.cost_type}: {data.amount}")
return {"status": "success"}
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
async with AsyncSessionLocal() as session:
costs = (await session.execute(text("SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, 'COST' as source FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC"), {"vid": vehicle_id})).fetchall()
logs = (await session.execute(text("SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, 'LOG' as source FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC"), {"vid": vehicle_id})).fetchall()
combined = [dict(r._mapping) for r in costs] + [dict(r._mapping) for r in logs]
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
@app.get("/api/my_vehicles")
async def get_my_garage():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = 1 AND vh.end_date IS NULL ORDER BY vh.start_date DESC"))
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in res.fetchall()]
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
async with AsyncSessionLocal() as session:
async with session.begin():
st = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": st, "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_REPORT", data.vehicle_id, data.description, "OK", st)
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_RESOLVED", data.vehicle_id, "Megjavítva", "WARNING", "OK")
return {"status": "success"}
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in (await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))).fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
vid = (await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": data.model_id, "vin": data.vin, "plt": data.plate})).scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)"), {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in (await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))).fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": str(uuid.uuid4())})
return {"status": "success"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,170 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v):
if len(v) < 5: raise ValueError('Túl rövid alvázszám')
return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v):
return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v):
if v < 0: raise ValueError('Negatív km nem lehet')
return v
class InviteRequest(BaseModel):
email: str
role: str
access_level: str
# --- VÉGPONTOK ---
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("""
SELECT vm.id, m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
"""))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role} for r in result.fetchall()]
# ÚJ: RÉSZLETES ADATLAP LEKÉRÉSE
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
# 1. Alapadatok
q_basic = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage,
u.default_currency -- A user pénzneme
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res_basic = await session.execute(q_basic, {"vid": vehicle_id, "uid": user_id})
car = res_basic.fetchone()
if not car:
raise HTTPException(status_code=404, detail="Jármű nem található vagy nincs hozzáférése")
# 2. Utolsó ismert km óraállás (a history-ból vagy költségekből)
# Egyelőre visszaadjuk a start_mileage-t, később itt számolunk
current_km = car.start_mileage
return {
"id": car.id,
"brand": car.brand,
"model": car.model_name,
"plate": car.current_plate,
"vin": car.vin,
"role": car.role,
"start_date": car.start_date,
"mileage": current_km,
"currency": car.default_currency
}
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
ins = text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id")
r = await session.execute(ins, {"mid": data.model_id, "vin": data.vin, "plt": data.plate})
vid = r.scalar()
hist = text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)")
await session.execute(hist, {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT u.email, fm.role, fm.joined_at, u.country
FROM data.fleet_members fm
JOIN data.users u ON fm.user_id = u.id
WHERE fm.owner_id = :uid
""")
result = await session.execute(query, {"uid": user_id})
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in result.fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
user_id = 1
token = str(uuid.uuid4())
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("""
INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at)
VALUES (:email, :uid, :role, :lvl, :token, NOW() + INTERVAL '7 days')
"""), {
"email": data.email, "uid": user_id, "role": data.role,
"lvl": data.access_level, "token": token
})
print(f"📧 EMAIL KÜLDÉSE: {data.email} -> Link: /invite/{token}")
return {"status": "success", "message": "Meghívó elküldve!", "debug_token": token}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"):
return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/main.py","entries":[{"id":"7zSH.py","timestamp":1768944686120},{"id":"gVs3.py","timestamp":1768944898840},{"id":"eaD4.py","timestamp":1768945068633},{"id":"0MBT.py","timestamp":1768945735412},{"id":"VR1d.py","timestamp":1768945973442},{"id":"2JOI.py","timestamp":1768946205711},{"id":"dYmD.py","timestamp":1768946545949},{"id":"HFdy.py","timestamp":1768946891365},{"id":"zNYa.py","timestamp":1768947662698},{"id":"HYjM.py","timestamp":1768947811545},{"id":"DXvc.py","timestamp":1768948349423},{"id":"TFdq.py","timestamp":1768953406975},{"id":"MoK7.py","timestamp":1768953978619},{"id":"gleo.py","timestamp":1768954265874},{"id":"z1at.py","timestamp":1768954519769},{"id":"WE3Y.py","timestamp":1768954748699},{"id":"sEal.py","timestamp":1768954903048}]}

View File

@@ -0,0 +1,134 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, validator
# KIVETTÜK AZ EmailStr-t, mert hiányzik a library hozzá!
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
from datetime import date
import uuid
from dotenv import load_dotenv
load_dotenv()
# DB Config
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class VehicleRegister(BaseModel):
model_id: int
vin: str
plate: str
mileage: int
purchase_date: date
role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v):
if len(v) < 5: raise ValueError('Túl rövid alvázszám')
return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v):
return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v):
if v < 0: raise ValueError('Negatív km nem lehet')
return v
class InviteRequest(BaseModel):
email: str # <--- JAVÍTVA: Sima string, nem EmailStr
role: str
access_level: str
# --- VÉGPONTOK ---
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
result = await session.execute(text("""
SELECT vm.id, m.name as brand, vm.model_name, vm.category
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY m.name, vm.model_name
"""))
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in result.fetchall()]
@app.get("/api/my_vehicles")
async def get_my_garage():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
ORDER BY vh.start_date DESC
""")
result = await session.execute(query, {"uid": user_id})
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role} for r in result.fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid:
ins = text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id")
r = await session.execute(ins, {"mid": data.model_id, "vin": data.vin, "plt": data.plate})
vid = r.scalar()
hist = text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)")
await session.execute(hist, {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
return {"status": "success"}
# --- FLOTTA VÉGPONTOK ---
@app.get("/api/fleet/members")
async def get_team_members():
user_id = 1
async with AsyncSessionLocal() as session:
query = text("""
SELECT u.email, fm.role, fm.joined_at, u.country
FROM data.fleet_members fm
JOIN data.users u ON fm.user_id = u.id
WHERE fm.owner_id = :uid
""")
result = await session.execute(query, {"uid": user_id})
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in result.fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
user_id = 1
token = str(uuid.uuid4())
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("""
INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at)
VALUES (:email, :uid, :role, :lvl, :token, NOW() + INTERVAL '7 days')
"""), {
"email": data.email, "uid": user_id, "role": data.role,
"lvl": data.access_level, "token": token
})
print(f"📧 EMAIL KÜLDÉSE: {data.email} -> Link: /invite/{token}")
return {"status": "success", "message": "Meghívó elküldve!", "debug_token": token}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"):
return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,106 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta, date
from jose import JWTError, jwt
import bcrypt, os, uuid, traceback
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
# --- AUTH ---
def get_password_hash(password: str):
pwd_bytes = password.encode('utf-8')
return bcrypt.hashpw(pwd_bytes[:72], bcrypt.gensalt()).decode('utf-8')
def verify_password(plain, hashed):
return bcrypt.checkpw(plain.encode('utf-8')[:72], hashed.encode('utf-8'))
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(payload.get("sub"))
except: raise HTTPException(status_code=401)
# --- API ---
@app.post("/api/auth/register")
async def register(email: str = Form(...), password: str = Form(...)):
async with AsyncSessionLocal() as session:
async with session.begin():
h = get_password_hash(password)
await session.execute(text("INSERT INTO data.users (email, password_hash) VALUES (:e, :p)"), {"e": email, "p": h})
return {"status": "success"}
@app.post("/api/auth/login")
async def login(f: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": f.username})
u = res.fetchone()
if not u or not verify_password(f.password, u.password_hash): raise HTTPException(status_code=401)
token = jwt.encode({"sub": str(u.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": token, "token_type": "bearer"}
@app.get("/api/my_vehicles")
async def my_vehicles(uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = :uid AND vh.end_date IS NULL")
res = await session.execute(q, {"uid": uid})
return [dict(r._mapping) for r in res.fetchall()]
@app.get("/api/vehicles")
async def all_models():
async with AsyncSessionLocal() as session:
q = text("SELECT vm.id, m.name as brand, vm.model_name as model FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY brand, model")
res = await session.execute(q)
return [dict(r._mapping) for r in res.fetchall()]
class VehicleReg(BaseModel): model_id: int; vin: str; plate: str; mileage: int; purchase_date: date
@app.post("/api/register")
async def register_vehicle(d: VehicleReg, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
# Új jármű vagy meglévő
res = await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": d.model_id, "vin": d.vin, "plt": d.plate})
vid = res.scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, :uid, 'OWNER', :sd, :sm)"), {"vid": vid, "uid": uid, "sd": d.purchase_date, "sm": d.mileage})
return {"status": "success"}
@app.get("/api/vehicle/{vid}")
async def get_details(vid: int, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id, v.current_plate as plate, m.name as brand, mo.model_name as model, vh.start_mileage as mileage, v.status, u.default_currency as currency FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id JOIN data.users u ON vh.user_id = u.id WHERE v.id = :vid AND vh.user_id = :uid")
res = await session.execute(q, {"vid": vid, "uid": uid})
car = res.fetchone()
if not car: raise HTTPException(status_code=404)
# Idei költség
c_q = text("SELECT SUM(amount) FROM data.costs WHERE vehicle_id = :vid AND EXTRACT(YEAR FROM date) = 2026")
cost = (await session.execute(c_q, {"vid": vid})).scalar() or 0
return {**dict(car._mapping), "year_cost": cost}
@app.post("/api/add_cost")
async def add_cost(vehicle_id: int = Form(...), cost_type: str = Form(...), amount: float = Form(...), currency: str = Form(...), mileage: int = Form(...), date_str: str = Form(...), uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost) VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil)"),
{"vid": vehicle_id, "uid": uid, "type": cost_type, "amt": amount, "curr": currency, "date": date_str, "mil": mileage})
return {"status": "success"}
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,81 @@
from fastapi import FastAPI, HTTPException, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta, date
from jose import jwt
import bcrypt, os, traceback
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
# --- AUTH ---
def get_password_hash(p): return bcrypt.hashpw(p.encode('utf-8')[:72], bcrypt.gensalt()).decode('utf-8')
def verify_password(p, h): return bcrypt.checkpw(p.encode('utf-8')[:72], h.encode('utf-8'))
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
p = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(p.get("sub"))
except: raise HTTPException(status_code=401)
@app.post("/api/auth/register")
async def register(email: str = Form(...), password: str = Form(...)):
try:
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.users (email, password_hash) VALUES (:e, :p)"),
{"e": email, "p": get_password_hash(password)})
return {"status": "success"}
except Exception as e:
return JSONResponse(status_code=400, content={"detail": "Email már létezik vagy adatbázis hiba."})
@app.post("/api/auth/login")
async def login(f: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": f.username})
u = res.fetchone()
if not u or not verify_password(f.password, u.password_hash): raise HTTPException(status_code=401, detail="Hibás adatok")
t = jwt.encode({"sub": str(u.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": t, "token_type": "bearer"}
# --- DATA ---
@app.get("/api/meta/vehicle-hierarchy")
async def get_hierarchy():
async with AsyncSessionLocal() as session:
q = text("SELECT vm.category, m.name as brand, vm.id as model_id, vm.model_name FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY vm.category, m.name")
res = await session.execute(q)
h = {}
for r in res:
if r.category not in h: h[r.category] = {}
if r.brand not in h[r.category]: h[r.category][r.brand] = []
h[r.category][r.brand].append({"id": r.model_id, "name": r.model_name})
return h
@app.get("/api/meta/cost-types")
async def get_cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name FROM ref.cost_types ORDER BY name"))
return {r.code: r.name for r in res.fetchall()}
@app.get("/api/my_vehicles")
async def my_vehicles(uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = :uid AND vh.end_date IS NULL")
res = await session.execute(q, {"uid": uid})
return [dict(r._mapping) for r in res.fetchall()]
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,97 @@
from fastapi import FastAPI, HTTPException, Form, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from datetime import datetime, timedelta, date
from jose import jwt
import bcrypt, os
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = "SZUPER_TITKOS_KULCS_2026"
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
DATABASE_URL = os.getenv("DATABASE_URL", "").replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI()
# --- AUTH HELPER ---
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
p = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(p.get("sub"))
except: raise HTTPException(status_code=401)
# --- DINAMIKUS METAADATOK (Ez az újdonság!) ---
@app.get("/api/meta/vehicle-hierarchy")
async def get_hierarchy():
async with AsyncSessionLocal() as session:
# Lekérjük az összes kategóriát, márkát és modellt egyben
q = text("""
SELECT vm.category, m.name as brand, vm.id as model_id, vm.model_name
FROM ref.vehicle_models vm
JOIN ref.vehicle_makes m ON vm.make_id = m.id
ORDER BY vm.category, m.name, vm.model_name
""")
res = await session.execute(q)
rows = res.fetchall()
hierarchy = {}
for r in rows:
cat = r.category
brand = r.brand
if cat not in hierarchy: hierarchy[cat] = {}
if brand not in hierarchy[cat]: hierarchy[cat][brand] = []
hierarchy[cat][brand].append({"id": r.model_id, "name": r.model_name})
return hierarchy
@app.get("/api/meta/cost-types")
async def get_cost_types():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT code, name FROM ref.cost_types ORDER BY name"))
return {r.code: r.name for r in res.fetchall()}
# --- CORE API ---
@app.post("/api/auth/login")
async def login(f: OAuth2PasswordRequestForm = Depends()):
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT id, password_hash FROM data.users WHERE email = :e"), {"e": f.username})
u = res.fetchone()
if not u or not bcrypt.checkpw(f.password.encode('utf-8')[:72], u.password_hash.encode('utf-8')):
raise HTTPException(status_code=401)
t = jwt.encode({"sub": str(u.id), "exp": datetime.utcnow() + timedelta(days=1)}, SECRET_KEY, algorithm=ALGORITHM)
return {"access_token": t, "token_type": "bearer"}
@app.get("/api/my_vehicles")
async def my_vehicles(uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id as vehicle_id, v.current_plate as plate, m.name as brand, mo.model_name as model, v.status
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
WHERE vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"uid": uid})
return [dict(r._mapping) for r in res.fetchall()]
class VehicleReg(BaseModel): model_id: int; vin: str; plate: str; mileage: int; purchase_date: date
@app.post("/api/register")
async def register_vehicle(d: VehicleReg, uid: int = Depends(get_current_user)):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": d.model_id, "vin": d.vin, "plt": d.plate})
vid = res.scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, :uid, 'OWNER', :sd, :sm)"), {"vid": vid, "uid": uid, "sd": d.purchase_date, "sm": d.mileage})
return {"status": "success"}
@app.get("/")
async def index(): return FileResponse("/app/frontend/index.html")

View File

@@ -0,0 +1,217 @@
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, validator
from typing import Optional, List
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
import os
import shutil
from datetime import date
import uuid
import traceback # Hogy lássuk a hibát
from dotenv import load_dotenv
load_dotenv()
# MAPPA KONFIGURÁCIÓ
UPLOAD_DIR = "/app/frontend/uploads"
# Megpróbáljuk létrehozni, ha nem menne, a scriptünk már megoldotta
try:
os.makedirs(UPLOAD_DIR, exist_ok=True)
except Exception as e:
print(f"HIBA a mappa létrehozásakor: {e}")
EXCHANGE_RATES = { "EUR_TO_HUF": 400.0, "HUF_TO_EUR": 0.0025 }
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
fixed_url = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
engine = create_async_engine(fixed_url)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
app = FastAPI(title="Service Finder API")
# --- MODELLEK ---
class IssueReport(BaseModel):
vehicle_id: int; description: str; is_critical: bool
class IssueResolve(BaseModel):
vehicle_id: int
class VehicleRegister(BaseModel):
model_id: int; vin: str; plate: str; mileage: int; purchase_date: date; role: str = "OWNER"
@validator('vin')
def clean_vin(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('plate')
def clean_plate(cls, v): return v.upper().replace("-", "").replace(" ", "")
@validator('mileage')
def positive_mileage(cls, v): return v if v >= 0 else 0
class InviteRequest(BaseModel):
email: str; role: str; access_level: str
# --- LOG HELPER ---
async def create_audit_log(session, user_id, event, target_id, details, old_val=None, new_val=None):
try:
await session.execute(text("INSERT INTO data.audit_logs (user_id, event_type, target_id, details, old_value, new_value) VALUES (:uid, :evt, :tid, :det, :old, :new)"),
{"uid": user_id, "evt": event, "tid": target_id, "det": details, "old": old_val, "new": new_val})
except Exception as e:
print(f"AUDIT LOG HIBA: {e}")
# --- API VÉGPONTOK (HIBATŰRŐ MÓDBAN) ---
@app.post("/api/add_cost")
async def add_cost(
vehicle_id: int = Form(...),
cost_type: str = Form(...),
amount: float = Form(...),
currency: str = Form(...),
mileage: int = Form(...),
date_str: str = Form(...),
description: str = Form(""),
file: UploadFile = File(None)
):
# ITT A LÉNYEG: TRY-EXCEPT BLOKK
try:
user_id = 1
document_path = None
# 1. Fájl mentése
if file:
try:
file_ext = file.filename.split(".")[-1]
unique_name = f"{uuid.uuid4()}.{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_name)
print(f"Fájl mentése ide: {file_path}") # Debug log
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
document_path = f"uploads/{unique_name}"
except Exception as file_error:
print(f"FÁJL MENTÉSI HIBA: {file_error}")
return JSONResponse(status_code=500, content={"detail": f"Nem sikerült a fájlt menteni: {str(file_error)}"})
# 2. Adatbázis mentés
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("""
INSERT INTO data.costs (vehicle_id, user_id, cost_type, amount, currency, date, mileage_at_cost, description, document_url)
VALUES (:vid, :uid, :type, :amt, :curr, :date, :mil, :desc, :doc)
"""), {
"vid": vehicle_id, "uid": user_id, "type": cost_type, "amt": amount,
"curr": currency, "date": date_str, "mil": mileage, "desc": description,
"doc": document_path
})
# Km frissítés
res = await session.execute(text("SELECT start_mileage FROM data.vehicle_history WHERE vehicle_id = :vid AND end_date IS NULL"), {"vid": vehicle_id})
current = res.scalar() or 0
if mileage > current:
await session.execute(text("UPDATE data.vehicle_history SET start_mileage = :mil WHERE vehicle_id = :vid AND end_date IS NULL"), {"mil": mileage, "vid": vehicle_id})
await create_audit_log(session, user_id, "MILEAGE_UPDATE", vehicle_id, f"Km: {mileage}")
await create_audit_log(session, user_id, "ADD_COST", vehicle_id, f"{cost_type}: {amount} {currency}")
return {"status": "success"}
except Exception as e:
# Ha bármi baj van, nem omlunk össze, hanem visszaszólunk a frontendnek!
error_msg = traceback.format_exc()
print(f"KRITIKUS HIBA A SZERVEREN: {error_msg}")
return JSONResponse(status_code=500, content={"detail": f"Szerver hiba: {str(e)}"})
# --- TÖBBI VÉGPONT (Változatlan, de működő) ---
@app.get("/api/vehicle/{vehicle_id}")
async def get_vehicle_details(vehicle_id: int):
user_id = 1
async with AsyncSessionLocal() as session:
q = text("""
SELECT v.id, v.vin, v.current_plate, v.production_year,
m.name as brand, mo.model_name, mo.category,
vh.role, vh.start_date, vh.start_mileage, u.default_currency,
v.status, v.current_issue
FROM data.vehicle_history vh
JOIN data.vehicles v ON vh.vehicle_id = v.id
JOIN ref.vehicle_models mo ON v.model_id = mo.id
JOIN ref.vehicle_makes m ON mo.make_id = m.id
JOIN data.users u ON vh.user_id = u.id
WHERE v.id = :vid AND vh.user_id = :uid AND vh.end_date IS NULL
""")
res = await session.execute(q, {"vid": vehicle_id, "uid": user_id})
car = res.fetchone()
if not car: raise HTTPException(status_code=404, detail="Nincs adat")
current_year = date.today().year
q_costs = text("SELECT amount, currency FROM data.costs WHERE vehicle_id = :vid AND EXTRACT(YEAR FROM date) = :year")
costs = (await session.execute(q_costs, {"vid": vehicle_id, "year": current_year})).fetchall()
total_cost = 0.0
user_curr = car.default_currency
for c in costs:
if c.currency == user_curr: total_cost += float(c.amount)
elif c.currency == 'EUR' and user_curr == 'HUF': total_cost += float(c.amount) * EXCHANGE_RATES["EUR_TO_HUF"]
elif c.currency == 'HUF' and user_curr == 'EUR': total_cost += float(c.amount) * EXCHANGE_RATES["HUF_TO_EUR"]
else: total_cost += float(c.amount)
return {"id": car.id, "brand": car.brand, "model": car.model_name, "plate": car.current_plate, "vin": car.vin, "role": car.role, "start_date": car.start_date, "mileage": car.start_mileage, "currency": car.default_currency, "status": car.status, "current_issue": car.current_issue, "year_cost": total_cost}
@app.get("/api/vehicle/{vehicle_id}/history")
async def get_vehicle_history(vehicle_id: int):
async with AsyncSessionLocal() as session:
costs = (await session.execute(text("SELECT id, cost_type as type, amount, currency, date, mileage_at_cost as mileage, description, document_url, 'COST' as source FROM data.costs WHERE vehicle_id = :vid ORDER BY date DESC"), {"vid": vehicle_id})).fetchall()
logs = (await session.execute(text("SELECT id, event_type as type, 0 as amount, '' as currency, created_at::date as date, 0 as mileage, details as description, '' as document_url, 'LOG' as source FROM data.audit_logs WHERE target_id = :vid AND event_type IN ('ISSUE_REPORT', 'ISSUE_RESOLVED') ORDER BY created_at DESC"), {"vid": vehicle_id})).fetchall()
combined = [dict(r._mapping) for r in costs] + [dict(r._mapping) for r in logs]
combined.sort(key=lambda x: x['date'], reverse=True)
return combined
@app.get("/api/my_vehicles")
async def get_my_garage():
async with AsyncSessionLocal() as session:
res = await session.execute(text("SELECT v.id as vehicle_id, v.vin, v.current_plate, m.name as brand, mo.model_name, mo.category, vh.start_mileage, vh.role, v.status, v.current_issue FROM data.vehicle_history vh JOIN data.vehicles v ON vh.vehicle_id = v.id JOIN ref.vehicle_models mo ON v.model_id = mo.id JOIN ref.vehicle_makes m ON mo.make_id = m.id WHERE vh.user_id = 1 AND vh.end_date IS NULL ORDER BY vh.start_date DESC"))
return [{"vehicle_id": r.vehicle_id, "brand": r.brand, "model": r.model_name, "plate": r.current_plate, "category": r.category, "role": r.role, "status": r.status, "current_issue": r.current_issue} for r in res.fetchall()]
@app.post("/api/report_issue")
async def report_issue(data: IssueReport):
async with AsyncSessionLocal() as session:
async with session.begin():
st = 'CRITICAL' if data.is_critical else 'WARNING'
await session.execute(text("UPDATE data.vehicles SET status = :st, current_issue = :desc WHERE id = :vid"), {"st": st, "desc": data.description, "vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_REPORT", data.vehicle_id, data.description, "OK", st)
return {"status": "success"}
@app.post("/api/resolve_issue")
async def resolve_issue(data: IssueResolve):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("UPDATE data.vehicles SET status = 'OK', current_issue = NULL WHERE id = :vid"), {"vid": data.vehicle_id})
await create_audit_log(session, 1, "ISSUE_RESOLVED", data.vehicle_id, "Megjavítva", "WARNING", "OK")
return {"status": "success"}
@app.get("/api/vehicles")
async def get_vehicles():
async with AsyncSessionLocal() as session:
return [{"id": r.id, "brand": r.brand, "model": r.model_name, "category": r.category} for r in (await session.execute(text("SELECT vm.id, m.name as brand, vm.model_name, vm.category FROM ref.vehicle_models vm JOIN ref.vehicle_makes m ON vm.make_id = m.id ORDER BY m.name"))).fetchall()]
@app.post("/api/register")
async def register_vehicle(data: VehicleRegister):
async with AsyncSessionLocal() as session:
async with session.begin():
res = await session.execute(text("SELECT id FROM data.vehicles WHERE vin = :vin"), {"vin": data.vin})
vid = res.scalar()
if not vid: vid = (await session.execute(text("INSERT INTO data.vehicles (model_id, vin, current_plate) VALUES (:mid, :vin, :plt) RETURNING id"), {"mid": data.model_id, "vin": data.vin, "plt": data.plate})).scalar()
await session.execute(text("INSERT INTO data.vehicle_history (vehicle_id, user_id, role, start_date, start_mileage) VALUES (:vid, 1, :role, :sd, :sm)"), {"vid": vid, "role": data.role, "sd": data.purchase_date, "sm": data.mileage})
await create_audit_log(session, 1, "REGISTER_VEHICLE", vid, "Regisztrálva")
return {"status": "success"}
@app.get("/api/fleet/members")
async def get_team_members():
async with AsyncSessionLocal() as session:
return [{"email": r.email, "role": r.role, "joined_at": r.joined_at, "country": r.country} for r in (await session.execute(text("SELECT u.email, fm.role, fm.joined_at, u.country FROM data.fleet_members fm JOIN data.users u ON fm.user_id = u.id WHERE fm.owner_id = 1"))).fetchall()]
@app.post("/api/fleet/invite")
async def invite_member(data: InviteRequest):
async with AsyncSessionLocal() as session:
async with session.begin():
await session.execute(text("INSERT INTO data.invitations (email, inviter_id, role, access_level, token, expires_at) VALUES (:e, 1, :r, :l, :t, NOW() + INTERVAL '7 days')"), {"e": data.email, "r": data.role, "l": data.access_level, "t": str(uuid.uuid4())})
return {"status": "success"}
@app.get("/")
async def serve_frontend():
if os.path.exists("/app/frontend/index.html"): return FileResponse("/app/frontend/index.html")
return {"error": "Frontend hiányzik"}

View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.schemas.user import UserResponse
from app.models.user import User
router = APIRouter()
# Ideiglenes mock user, amíg nincs JWT auth
async def get_mock_current_user_id():
return 2 # Good Guy ID
@router.get("/me", response_model=UserResponse)
async def read_users_me(
db: AsyncSession = Depends(get_db),
user_id: int = Depends(get_mock_current_user_id)
):
"""Visszaadja a bejelentkezett felhasználó profilját (Hírnévvel!)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/api/v1/users.py","entries":[{"id":"IRaR.py","timestamp":1769035906524}]}

View File

@@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models.social import ServiceProvider, ModerationStatus
from app.schemas.social import ServiceProviderCreate
class SocialService:
@staticmethod
async def create_service_provider(db: AsyncSession, schema: ServiceProviderCreate, user_id: int) -> ServiceProvider:
# 1. Pydantic modell átalakítása SQLAlchemy modellé
# A **schema.model_dump() kicsomagolja a mezőket (name, address, source)
db_provider = ServiceProvider(
**schema.model_dump(),
added_by_user_id=user_id,
status=ModerationStatus.PENDING # KRITIKUS: Alapértelmezetten moderációra vár
)
# 2. Mentés az adatbázisba
db.add(db_provider)
await db.commit()
await db.refresh(db_provider) # Visszakérjük az ID-t és a timestamp-et
return db_provider
@staticmethod
async def get_all_providers(db: AsyncSession):
# Teszteléshez: listázzuk ki az összeset
result = await db.execute(select(ServiceProvider))
return result.scalars().all()

View File

@@ -0,0 +1,109 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_, desc
from app.models.social import ServiceProvider, Vote, ModerationStatus, Competition, UserScore
from app.models.user import User
from datetime import datetime
async def vote_for_provider(db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
"""
Fő logika: Szavazás kezelése -> Validáció -> Jutalmazás vagy Büntetés
"""
# 1. Ellenőrizzük, szavazott-e már (Upsert logika helyett most egyszerű check)
result = await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))
existing_vote = result.scalars().first()
if existing_vote:
# Ha már szavazott, most kihagyjuk a módosítást az egyszerűség kedvéért,
# de itt lehetne update-elni a szavazatot.
return {"message": "User already voted"}
# 2. Új szavazat rögzítése
new_vote = Vote(user_id=voter_id, provider_id=provider_id, vote_value=vote_value)
db.add(new_vote)
# 3. Szolgáltató pontszámának frissítése
provider_result = await db.execute(select(ServiceProvider).where(ServiceProvider.id == provider_id))
provider = provider_result.scalars().first()
if not provider:
return {"error": "Provider not found"}
provider.validation_score += vote_value
# --- THRESHOLD LOGIC (A Lényeg) ---
# ESET A: JÓVÁHAGYÁS (Score >= 5)
if provider.status == ModerationStatus.pending and provider.validation_score >= 5:
provider.status = ModerationStatus.approved
# Jutalmazás
await _reward_submitter(db, provider.added_by_user_id)
# ESET B: ELUTASÍTÁS (Score <= -3)
elif provider.status == ModerationStatus.pending and provider.validation_score <= -3:
provider.status = ModerationStatus.rejected
# Büntetés
await _penalize_user(db, provider.added_by_user_id)
await db.commit()
return {"message": "Vote cast successfully", "new_score": provider.validation_score, "status": provider.status}
async def _reward_submitter(db: AsyncSession, user_id: int):
"""Jutalmazza a feltöltőt: Hírnév + Verseny Pontok"""
if not user_id:
return
# 1. Hírnév növelése
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalars().first()
if user:
user.reputation_score += 1
print(f"--- REWARD: User {user_id} reputation increased to {user.reputation_score}")
# 2. Gamification: Aktív verseny keresése
now = datetime.utcnow()
comp_result = await db.execute(select(Competition).where(
and_(Competition.is_active == True, Competition.start_date <= now, Competition.end_date >= now)
))
active_competition = comp_result.scalars().first()
if active_competition:
# Pontszerzés a versenyben
score_result = await db.execute(select(UserScore).where(
and_(UserScore.user_id == user_id, UserScore.competition_id == active_competition.id)
))
user_score = score_result.scalars().first()
if not user_score:
user_score = UserScore(user_id=user_id, competition_id=active_competition.id, points=0)
db.add(user_score)
user_score.points += 10
print(f"--- GAMIFICATION: User {user_id} awarded 10 points in '{active_competition.name}'")
async def _penalize_user(db: AsyncSession, user_id: int):
"""Bünteti a rossz feltöltőt: Hírnév csökkentés + Auto Ban"""
if not user_id:
return
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalars().first()
if user:
user.reputation_score -= 2
print(f"--- PENALTY: User {user_id} reputation decreased to {user.reputation_score}")
# Auto-Ban logika
if user.reputation_score <= -10:
user.is_active = False
print(f"--- SECURITY ALERT: User {user_id} has been AUTO-BANNED due to low reputation!")
async def get_leaderboard(db: AsyncSession, competition_id: int):
"""Visszaadja a TOP 10 felhasználót"""
result = await db.execute(
select(UserScore, User.full_name)
.join(User, UserScore.user_id == User.id)
.where(UserScore.competition_id == competition_id)
.order_by(desc(UserScore.points))
.limit(10)
)
return result.all()

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/services/social_service.py","entries":[{"id":"RO9K.py","timestamp":1769026150839},{"id":"V3OE.py","timestamp":1769033811793}]}

View File

@@ -0,0 +1,69 @@
import asyncio
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
# CSERÉLD KI a saját adataidra, ha nem ezeket használod!
DATABASE_URL = "postgresql+asyncpg://kincses:MiskociA74@192.168.100.43:5432/fleet_db"
async def run_inspection():
# Aszinkron motor létrehozása
engine = create_async_engine(DATABASE_URL)
async with engine.connect() as conn:
def do_inspect(sync_conn):
inspector = inspect(sync_conn)
# 1. Sémák lekérése (amiket mi hoztunk létre + public)
target_schemas = ['public', 'data', 'ref']
all_schemas = inspector.get_schema_names()
schemas_to_check = [s for s in target_schemas if s in all_schemas]
print(f"{'='*60}")
print(f" JÁRMŰNYILVÁNTARTÓ RENDSZER - ADATBÁZIS AUDIT")
print(f"{'='*60}")
for schema in schemas_to_check:
print(f"\n--- SÉMA: {schema.upper()} ---")
tables = inspector.get_table_names(schema=schema)
if not tables:
print(" (Nincsenek táblák ebben a sémában)")
continue
for table_name in tables:
print(f"\n[Tábla: {schema}.{table_name}]")
# Oszlopok részletei
columns = inspector.get_columns(table_name, schema=schema)
for col in columns:
pk = " [PK]" if col['primary_key'] else ""
nullable = "NULL" if col['nullable'] else "NOT NULL"
print(f" L {col['name']:<15} | {str(col['type']):<12} | {nullable}{pk}")
# Idegen kulcsok (Kapcsolatok)
fks = inspector.get_foreign_keys(table_name, schema=schema)
for fk in fks:
constrained = fk['constrained_columns']
referred_schema = fk['referred_schema']
referred_table = fk['referred_table']
referred_cols = fk['referred_columns']
print(f" --> Kapcsolat: {constrained} -> {referred_schema}.{referred_table}({referred_cols})")
# Mivel az SQLAlchemy inspector alapvetően szinkron, run_sync-et használunk
await conn.run_sync(do_inspect)
# 2. Extra: Kiterjesztések ellenőrzése
print(f"\n{'='*60}")
print(" AKTÍV POSTGRESQL KITERJESZTÉSEK:")
result = await conn.execute(text("SELECT extname FROM pg_extension;"))
for row in result:
print(f" - {row[0]}")
print(f"{'='*60}")
await engine.dispose()
if __name__ == "__main__":
try:
asyncio.run(run_inspection())
except Exception as e:
print(f"HIBA TÖRTÉNT: {e}")

View File

@@ -0,0 +1,70 @@
import asyncio
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
# CSERÉLD KI a saját adataidra, ha nem ezeket használod!
DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/fleet_db"
async def run_inspection():
# Aszinkron motor létrehozása
engine = create_async_engine(DATABASE_URL)
async with engine.connect() as conn:
def do_inspect(sync_conn):
inspector = inspect(sync_conn)
# 1. Sémák lekérése (amiket mi hoztunk létre + public)
target_schemas = ['public', 'data', 'ref']
all_schemas = inspector.get_schema_names()
schemas_to_check = [s for s in target_schemas if s in all_schemas]
print(f"{'='*60}")
print(f" JÁRMŰNYILVÁNTARTÓ RENDSZER - ADATBÁZIS AUDIT")
print(f"{'='*60}")
for schema in schemas_to_check:
print(f"\n--- SÉMA: {schema.upper()} ---")
tables = inspector.get_table_names(schema=schema)
if not tables:
print(" (Nincsenek táblák ebben a sémában)")
continue
for table_name in tables:
print(f"\n[Tábla: {schema}.{table_name}]")
# Oszlopok részletei
columns = inspector.get_columns(table_name, schema=schema)
for col in columns:
pk = " [PK]" if col['primary_key'] else ""
nullable = "NULL" if col['nullable'] else "NOT NULL"
print(f" L {col['name']:<15} | {str(col['type']):<12} | {nullable}{pk}")
# Idegen kulcsok (Kapcsolatok)
fks = inspector.get_foreign_keys(table_name, schema=schema)
for fk in fks:
constrained = fk['constrained_columns']
referred_schema = fk['referred_schema']
referred_table = fk['referred_table']
referred_cols = fk['referred_columns']
print(f" --> Kapcsolat: {constrained} -> {referred_schema}.{referred_table}({referred_cols})")
# Mivel az SQLAlchemy inspector alapvetően szinkron, run_sync-et használunk
await conn.run_sync(do_inspect)
# 2. Extra: Kiterjesztések ellenőrzése
print(f"\n{'='*60}")
print(" AKTÍV POSTGRESQL KITERJESZTÉSEK:")
result = await conn.execute(text("SELECT extname FROM pg_extension;"))
for row in result:
print(f" - {row[0]}")
print(f"{'='*60}")
await engine.dispose()
if __name__ == "__main__":
try:
asyncio.run(run_inspection())
except Exception as e:
print(f"HIBA TÖRTÉNT: {e}")

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/inspect_db_full.py","entries":[{"id":"MvHD.py","timestamp":1769019848101},{"id":"30t5.py","timestamp":1769019972199},{"id":"wfWY.py","timestamp":1769020004838}]}

View File

@@ -0,0 +1,69 @@
import asyncio
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
# CSERÉLD KI a saját adataidra, ha nem ezeket használod!
DATABASE_URL = "postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder"
async def run_inspection():
# Aszinkron motor létrehozása
engine = create_async_engine(DATABASE_URL)
async with engine.connect() as conn:
def do_inspect(sync_conn):
inspector = inspect(sync_conn)
# 1. Sémák lekérése (amiket mi hoztunk létre + public)
target_schemas = ['public', 'data', 'ref']
all_schemas = inspector.get_schema_names()
schemas_to_check = [s for s in target_schemas if s in all_schemas]
print(f"{'='*60}")
print(f" JÁRMŰNYILVÁNTARTÓ RENDSZER - ADATBÁZIS AUDIT")
print(f"{'='*60}")
for schema in schemas_to_check:
print(f"\n--- SÉMA: {schema.upper()} ---")
tables = inspector.get_table_names(schema=schema)
if not tables:
print(" (Nincsenek táblák ebben a sémában)")
continue
for table_name in tables:
print(f"\n[Tábla: {schema}.{table_name}]")
# Oszlopok részletei
columns = inspector.get_columns(table_name, schema=schema)
for col in columns:
pk = " [PK]" if col['primary_key'] else ""
nullable = "NULL" if col['nullable'] else "NOT NULL"
print(f" L {col['name']:<15} | {str(col['type']):<12} | {nullable}{pk}")
# Idegen kulcsok (Kapcsolatok)
fks = inspector.get_foreign_keys(table_name, schema=schema)
for fk in fks:
constrained = fk['constrained_columns']
referred_schema = fk['referred_schema']
referred_table = fk['referred_table']
referred_cols = fk['referred_columns']
print(f" --> Kapcsolat: {constrained} -> {referred_schema}.{referred_table}({referred_cols})")
# Mivel az SQLAlchemy inspector alapvetően szinkron, run_sync-et használunk
await conn.run_sync(do_inspect)
# 2. Extra: Kiterjesztések ellenőrzése
print(f"\n{'='*60}")
print(" AKTÍV POSTGRESQL KITERJESZTÉSEK:")
result = await conn.execute(text("SELECT extname FROM pg_extension;"))
for row in result:
print(f" - {row[0]}")
print(f"{'='*60}")
await engine.dispose()
if __name__ == "__main__":
try:
asyncio.run(run_inspection())
except Exception as e:
print(f"HIBA TÖRTÉNT: {e}")

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/update_permissions.py","entries":[{"id":"rXIc.py","timestamp":1768943765757}]}

View File

@@ -0,0 +1,61 @@
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from dotenv import load_dotenv
load_dotenv()
raw_url = os.getenv("DATABASE_URL")
if not raw_url:
raw_url = "postgresql://admin:PASSWORD_111@postgres-db:5432/service_finder"
DATABASE_URL = raw_url.replace("postgresql://", "postgresql+asyncpg://").replace("/service_finder_db", "/service_finder")
async def upgrade_permissions():
print(f"🔌 Kapcsolódás...")
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
print("👑 USERS tábla bővítése (Rendszer és Szervezeti szintek)...")
# 1. SYS_ROLE: Rendszergazda / Moderátor / User
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS sys_role VARCHAR(20) DEFAULT 'USER';
"""))
# 2. HIERARCHIA: Ki a főnököd? (Parent User ID)
# Ha NULL, akkor ő a Cégtulajdonos (SuperUser). Ha van ID, akkor Alkalmazott.
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES data.users(id);
"""))
# 3. ORG_ROLE: Cégen belüli szerep (Owner, Fleet Manager, Employee)
await conn.execute(text("""
ALTER TABLE data.users
ADD COLUMN IF NOT EXISTS org_role VARCHAR(20) DEFAULT 'OWNER';
"""))
print("🚦 VEHICLE_HISTORY tábla bővítése (Sofőr jogosultságok)...")
# 4. ACCESS_LEVEL: Mit láthat a sofőr? (COST_MANAGER vs LOG_ONLY)
await conn.execute(text("""
ALTER TABLE data.vehicle_history
ADD COLUMN IF NOT EXISTS access_level VARCHAR(20) DEFAULT 'FULL';
-- Lehetséges értékek: 'FULL', 'COST_MANAGER', 'LOG_ONLY'
"""))
# --- DEMO ADATOK FRISSÍTÉSE ---
print("👤 Demo User (ID:1) kinevezése Rendszergazdának és Cégtulajdonosnak...")
await conn.execute(text("""
UPDATE data.users
SET sys_role = 'SYS_ADMIN', org_role = 'OWNER', parent_id = NULL
WHERE id = 1;
"""))
print("✅ KÉSZ! A jogosultsági mátrix beépítve az adatbázisba.")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(upgrade_permissions())

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; display: flex; flex-direction: column; justify-content: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; margin-top: 5px; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #6c757d; }
.history-icon.FUEL { border-color: #198754; background: #198754; }
.history-icon.PURCHASE { border-color: #0d6efd; background: #0d6efd; }
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; }
.history-icon.INSURANCE { border-color: #6610f2; background: #6610f2; }
.history-icon.TAX { border-color: #dc3545; background: #dc3545; }
.history-icon.ISSUE_REPORT { border-color: #212529; background: #212529; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4"><li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li></ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end"><div class="plate-badge">{{ car.plate }}</div><span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}</span></div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div><h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2><div class="d-flex align-items-center mt-1 gap-2"><span class="plate-badge fs-6">{{ selectedCar.plate }}</span><span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span></div></div>
</div>
<div class="text-end"><button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button><button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség</button></div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3"><li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li><li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li></ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-3">
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div></div>
<div class="col-md-3"><div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'"><div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div><div class="stat-label">Állapot</div></div></div>
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div><div class="stat-label">Idei költés</div></div></div>
<div class="col-md-3"><div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger p-2 m-0 d-flex flex-column justify-content-center"><h6 class="fw-bold text-danger mb-1" style="font-size: 0.9rem">Hiba:</h6><p class="mb-2 text-dark small" style="line-height: 1.2">{{ selectedCar.current_issue }}</p><button class="btn btn-sm btn-danger w-100" @click="resolveIssue">Megjavítva</button></div><div v-else class="stat-box text-muted border bg-light"><div class="fs-4"><i class="bi bi-shield-check"></i></div><div class="stat-label">Nincs hiba</div></div></div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">Még üres a szervizkönyv.</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type.split('_')[0]"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">{{ translateType(item.type) }}<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span></h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<div class="d-flex align-items-center gap-2 text-muted small mt-1"><span v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</span><a v-if="item.document_url" :href="item.document_url" target="_blank" class="badge bg-primary text-decoration-none"><i class="bi bi-paperclip me-1"></i>Csatolmány</a></div>
</div>
<div class="text-end"><span class="badge bg-light text-dark border">{{ item.date }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-6"><label class="form-label fw-bold small">Kategória</label><select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory"><option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option></select></div>
<div class="col-6"><label class="form-label fw-bold small">Típus</label><select class="form-select" v-model="costForm.subCategory" :disabled="!currentSubOptions"><option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option></select></div>
<div class="col-7"><label class="form-label fw-bold small">Összeg</label><input type="number" class="form-control" v-model="costForm.amount"></div>
<div class="col-5"><label class="form-label fw-bold small">Pénznem</label><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
<div class="col-6"><label class="form-label fw-bold small">Km állás</label><input type="number" class="form-control" v-model="costForm.mileage"></div>
<div class="col-6"><label class="form-label fw-bold small">Dátum</label><input type="date" class="form-control" v-model="costForm.date"></div>
<div class="col-12"><label class="form-label small">Megjegyzés</label><textarea class="form-control" rows="2" v-model="costForm.description"></textarea></div>
<div class="col-12"><label class="form-label fw-bold small"><i class="bi bi-paperclip me-1"></i>Bizonylat</label><input type="file" class="form-control" ref="fileInput"></div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4"><button class="btn btn-light" @click="showCostModal = false">Mégse</button><button class="btn btn-success" @click="submitCost">Mentés</button></div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom"><div class="modal-content-custom bg-danger bg-opacity-10 border border-danger"><h4 class="fw-bold text-danger">Hiba</h4><textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea><div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div><div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div></div></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return {
view: 'dashboard', detailTab: 'overview', showErrorModal: false, showCostModal: false, myCars: [], selectedCar: null, history: [], errorForm: {description: '', is_critical: false},
costForm: { mainCategory: 'FUEL', subCategory: '', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' },
costDefinitions: {} // MOST MÁR ÜRES, AZ API TÖLTI KI!
}},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory] ? this.costDefinitions[this.costForm.mainCategory].subs : null; }
},
methods: {
async fetchRefData() {
// ITT KÉRDEZZÜK LE AZ ADATBÁZISBÓL!
const res = await fetch('/api/ref/cost_types');
this.costDefinitions = await res.json();
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0]; // Az elsőt kijelöljük
},
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) { const res = await fetch('/api/vehicle/' + id); this.selectedCar = await res.json(); this.view = 'detail'; this.detailTab = 'overview'; },
openCostModal() {
this.costForm = { mainCategory: Object.keys(this.costDefinitions)[0], subCategory: '', amount: '', currency: this.selectedCar.currency || 'HUF', mileage: this.selectedCar.mileage, date: new Date().toISOString().split('T')[0], description: '' };
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory].subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const finalType = this.costForm.subCategory || this.costForm.mainCategory;
const formData = new FormData();
formData.append('vehicle_id', this.selectedCar.id); formData.append('cost_type', finalType); formData.append('amount', this.costForm.amount); formData.append('currency', this.costForm.currency); formData.append('mileage', this.costForm.mileage); formData.append('date_str', this.costForm.date); formData.append('description', this.costForm.description);
const fileInput = this.$refs.fileInput; if(fileInput.files.length > 0) formData.append('file', fileInput.files[0]);
try {
const res = await fetch('/api/add_cost', { method: 'POST', body: formData });
if (res.ok) { alert("Költség rögzítve!"); this.showCostModal = false; this.openVehicleDetail(this.selectedCar.id); if(this.detailTab === 'history') this.loadHistory(); } else { alert("Hiba: " + JSON.stringify(await res.json())); }
} catch(e) { alert("Szerver hiba!"); }
},
async loadHistory() { this.detailTab = 'history'; const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history'); this.history = await res.json(); },
formatMoney(amount, currency) { try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); } catch(e) { return amount + " " + currency; } },
translateType(type) {
if(!this.costDefinitions) return type;
if (this.costDefinitions[type]) return this.costDefinitions[type].label;
for (const main in this.costDefinitions) {
if (this.costDefinitions[main].subs && this.costDefinitions[main].subs[type]) return this.costDefinitions[main].subs[type];
}
const map = { 'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció' };
return map[type] || type;
},
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() { await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) }); this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id); },
async resolveIssue() { if(!confirm("Megjavítva?")) return; await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) }); this.openVehicleDetail(this.selectedCar.id); }
},
mounted() { this.fetchData(); this.fetchRefData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Flotta Kezelő</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
/* Auth Képernyő */
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
/* Alkalmazás stílusok */
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div v-if="authView === 'login'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="authView='register'">Regisztrálj!</a></p>
</div>
<div v-if="authView === 'register'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-success w-100 py-2" @click="register">Regisztráció</button>
<p class="text-center mt-3 small"><a href="#" @click="authView='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary shadow-sm px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="d-flex align-items-center">
<span class="text-white me-3 small">{{ userEmail }}</span>
<button class="btn btn-outline-light btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold">Saját Járművek</h4>
<button class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Új jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="plate-badge">{{ car.plate }}</span>
<i v-if="car.status !== 'OK'" class="bi bi-exclamation-triangle text-danger"></i>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none ps-0 mb-3" @click="view='dashboard'"><i class="bi bi-arrow-left"></i> Vissza a listához</button>
<div class="detail-header shadow-sm border">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="fw-bold mb-1">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="text-end">
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
</div>
<div class="tab-content-area shadow-sm">
<div class="row text-center">
<div class="col-4 border-end"><h6>Km állás</h6><div class="fw-bold fs-4">{{ selectedCar.mileage }}</div></div>
<div class="col-4 border-end"><h6>Idei költség</h6><div class="fw-bold fs-4">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div>
<div class="col-4"><h6>Állapot</h6><div :class="selectedCar.status === 'OK' ? 'text-success' : 'text-danger'" class="fw-bold fs-4">{{ selectedCar.status }}</div></div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom auth-wrapper" style="position:fixed; top:0; left:0; width:100%; background:rgba(0,0,0,0.5); z-index:9999;">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Költség Rögzítése</h4>
<div class="row g-3">
<div class="col-6">
<label class="small fw-bold">Kategória</label>
<select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory">
<option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Típus</label>
<select class="form-select" v-model="costForm.subCategory">
<option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option>
</select>
</div>
<div class="col-6"><input type="number" class="form-control" v-model="costForm.amount" placeholder="Összeg"></div>
<div class="col-6"><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
</div>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-light me-2" @click="showCostModal=false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
userEmail: '',
authView: 'login',
view: 'dashboard',
authForm: { email: '', password: '' },
myCars: [],
selectedCar: null,
showCostModal: false,
costForm: { mainCategory: '', subCategory: '', amount: '', currency: 'HUF' },
costDefinitions: {}
}
},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory]?.subs || null; }
},
methods: {
// --- AUTH ---
async login() {
const params = new URLSearchParams();
params.append('username', this.authForm.email); // Az email megy a username mezőbe
params.append('password', this.authForm.password);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
this.isLoggedIn = true;
this.initApp();
} else {
const errorData = await res.json();
alert("Belépés elutasítva: " + (errorData.detail || "Ismeretlen hiba"));
}
} catch (e) {
alert("Szerver hiba a bejelentkezéskor!");
}
}
},
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
await fetch('/api/auth/register', { method: 'POST', body: fd });
alert("Sikeres regisztráció! Jelentkezz be.");
this.authView = 'login';
},
logout() { localStorage.removeItem('token'); this.isLoggedIn = false; },
// --- API HELPERS (JWT Token-nel!) ---
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` };
const res = await fetch(url, options);
if(res.status === 401) this.logout();
return res;
},
// --- ALKALMAZÁS LOGIKA ---
async initApp() {
if(!this.isLoggedIn) return;
const resCars = await this.apiFetch('/api/my_vehicles');
this.myCars = await resCars.json();
const resTypes = await fetch('/api/ref/cost_types'); // Ez publikus is lehet
this.costDefinitions = await resTypes.json();
},
async openVehicleDetail(id) {
const res = await this.apiFetch(`/api/vehicle/${id}`);
this.selectedCar = await res.json();
this.view = 'detail';
},
openCostModal() {
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory]?.subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.subCategory || this.costForm.mainCategory);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.costForm.currency);
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const res = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(res.ok) {
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
}
},
formatMoney(amount, curr) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: curr }).format(amount); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; display: flex; flex-direction: column; justify-content: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; margin-top: 5px; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #0d6efd; }
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; }
.history-icon.FUEL { border-color: #198754; background: #198754; }
.history-icon.ISSUE_REPORT { border-color: #dc3545; background: #dc3545; }
.history-icon.ISSUE_RESOLVED { border-color: #0d6efd; background: #0d6efd; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4"><li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li></ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-3">
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div></div>
<div class="col-md-3"><div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'"><div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div><div class="stat-label">Állapot</div></div></div>
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div><div class="stat-label">Idei költés</div></div></div>
<div class="col-md-3">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger p-2 m-0 d-flex flex-column justify-content-center">
<h6 class="fw-bold text-danger mb-1" style="font-size: 0.9rem">Hiba:</h6><p class="mb-2 text-dark small" style="line-height: 1.2">{{ selectedCar.current_issue }}</p><button class="btn btn-sm btn-danger w-100" @click="resolveIssue">Megjavítva</button>
</div>
<div v-else class="stat-box text-muted border bg-light"><div class="fs-4"><i class="bi bi-shield-check"></i></div><div class="stat-label">Nincs hiba</div></div>
</div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">Még üres a szervizkönyv.</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">
{{ translateType(item.type) }}
<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span>
</h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<div class="d-flex align-items-center gap-2 text-muted small mt-1">
<span v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</span>
<a v-if="item.document_url" :href="item.document_url" target="_blank" class="badge bg-primary text-decoration-none">
<i class="bi bi-paperclip me-1"></i>Csatolmány
</a>
</div>
</div>
<div class="text-end"><span class="badge bg-light text-dark border">{{ item.date }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-12"><label class="form-label fw-bold">Típus</label><select class="form-select" v-model="costForm.type"><option value="FUEL">⛽ Tankolás</option><option value="SERVICE">🔧 Szerviz</option><option value="INSURANCE">📄 Biztosítás</option><option value="TAX">🏛️ Adó</option><option value="OTHER">Egyéb</option></select></div>
<div class="col-7"><label class="form-label fw-bold">Összeg</label><input type="number" class="form-control" v-model="costForm.amount"></div>
<div class="col-5"><label class="form-label fw-bold">Pénznem</label><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
<div class="col-6"><label class="form-label fw-bold">Km</label><input type="number" class="form-control" v-model="costForm.mileage"></div>
<div class="col-6"><label class="form-label fw-bold">Dátum</label><input type="date" class="form-control" v-model="costForm.date"></div>
<div class="col-12"><label class="form-label">Megjegyzés</label><textarea class="form-control" rows="2" v-model="costForm.description"></textarea></div>
<div class="col-12"><label class="form-label fw-bold"><i class="bi bi-paperclip me-1"></i>Bizonylat (Kép)</label><input type="file" class="form-control" ref="fileInput"></div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4"><button class="btn btn-light" @click="showCostModal = false">Mégse</button><button class="btn btn-success" @click="submitCost">Mentés</button></div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom"><div class="modal-content-custom bg-danger bg-opacity-10 border border-danger"><h4 class="fw-bold text-danger">Hiba</h4><textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea><div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div><div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div></div></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return { view: 'dashboard', detailTab: 'overview', showErrorModal: false, showCostModal: false, myCars: [], selectedCar: null, history: [], errorForm: {description: '', is_critical: false}, costForm: { type: 'FUEL', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' } }},
methods: {
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) { const res = await fetch('/api/vehicle/' + id); this.selectedCar = await res.json(); this.view = 'detail'; this.detailTab = 'overview'; },
openCostModal() { this.costForm = { type: 'FUEL', amount: '', currency: this.selectedCar.currency || 'HUF', mileage: this.selectedCar.mileage, date: new Date().toISOString().split('T')[0], description: '' }; this.showCostModal = true; },
async submitCost() {
const formData = new FormData();
formData.append('vehicle_id', this.selectedCar.id);
formData.append('cost_type', this.costForm.type);
formData.append('amount', this.costForm.amount);
formData.append('currency', this.costForm.currency);
formData.append('mileage', this.costForm.mileage);
formData.append('date_str', this.costForm.date);
formData.append('description', this.costForm.description);
const fileInput = this.$refs.fileInput;
if(fileInput.files.length > 0) {
formData.append('file', fileInput.files[0]);
}
try {
const res = await fetch('/api/add_cost', { method: 'POST', body: formData });
// --- HIBAKEZELÉS (EZ HIÁNYZOTT!) ---
if (res.ok) {
alert("Költség és fájl rögzítve!");
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
if(this.detailTab === 'history') this.loadHistory();
} else {
// Ha a szerver hibát dob, olvassuk ki és jelezzük!
const err = await res.json();
alert("Hiba történt a mentéskor: " + JSON.stringify(err));
console.error(err);
}
} catch(e) {
alert("Kritikus hiba: A szerver nem válaszol. Fut a 'python-multipart'?");
console.error(e);
}
},
async loadHistory() { this.detailTab = 'history'; const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history'); this.history = await res.json(); },
formatMoney(amount, currency) { try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); } catch(e) { return amount + " " + currency; } },
translateType(type) { const map = { 'FUEL': 'Tankolás', 'SERVICE': 'Szerviz', 'INSURANCE': 'Biztosítás', 'TAX': 'Adó', 'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció' }; return map[type] || type; },
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() { await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) }); this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id); },
async resolveIssue() { if(!confirm("Megjavítva?")) return; await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) }); this.openVehicleDetail(this.selectedCar.id); }
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<title>Szerviz Kereső - Flotta Menedzser</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background: #f0f2f5; font-family: 'Inter', sans-serif; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.navbar { background: white !important; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.garage-card { border: none; border-radius: 15px; transition: 0.3s; cursor: pointer; background: white; }
.garage-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.08); }
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-expand-lg navbar-light px-4 mb-4" v-if="isLoggedIn">
<div class="container">
<a class="navbar-brand fw-bold text-primary" href="#" @click="view='garage'"><i class="bi bi-tools me-2"></i>SZERVIZ KERESŐ</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" :class="{active: view==='garage'}" @click="view='garage'" href="#">Garázs</a></li>
<li class="nav-item"><a class="nav-link" href="#">Szervizek</a></li>
<li class="nav-item"><a class="nav-link" href="#">Csapatom</a></li>
</ul>
<div class="d-flex align-items-center">
<select class="form-select form-select-sm me-3" style="width: auto;"><option>🇭🇺 HU</option><option>🇬🇧 EN</option></select>
<button class="btn btn-outline-danger btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</div>
</nav>
<div v-if="!isLoggedIn" class="d-flex align-items-center justify-content-center" style="height:100vh">
<div class="auth-card">
<h2 class="text-center fw-bold mb-4 text-primary">Service Finder</h2>
<div v-if="authMode === 'login'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email cím">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="handleLogin">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs még fiókod? <a href="#" @click="authMode='register'">Regisztráció</a></p>
</div>
<div v-else>
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Új Email cím">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Új Jelszó">
<button class="btn btn-success w-100 py-2" @click="handleRegister">Fiók létrehozása</button>
<p class="text-center mt-3 small"><a href="#" @click="authMode='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else class="container">
<div v-if="view === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="fw-bold">Garázsom</h3>
<button class="btn btn-primary" @click="modals.reg = true"><i class="bi bi-plus-lg me-2"></i>Új Jármű</button>
</div>
<div class="row g-4">
<div class="col-md-4" v-for="car in myCars">
<div class="card garage-card p-4 shadow-sm" @click="selectedCar=car; view='detail'">
<div class="d-flex justify-content-between mb-2">
<h5 class="fw-bold mb-0">{{car.brand}}</h5>
<span class="badge bg-warning text-dark">{{car.plate}}</span>
</div>
<div class="text-muted small">{{car.model}}</div>
<div class="mt-3 d-flex justify-content-between">
<button class="btn btn-sm btn-outline-success" @click.stop="openCost(car)">Költség +</button>
<button class="btn btn-sm btn-outline-secondary">Részletek</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link ps-0 mb-3 text-decoration-none" @click="view='garage'"><i class="bi bi-arrow-left"></i> Vissza</button>
<div class="card p-4 rounded-4 border-0 shadow-sm">
<h2 class="fw-bold">{{selectedCar.brand}} {{selectedCar.model}}</h2>
<p class="text-muted">Rendszám: {{selectedCar.plate}}</p>
<hr>
<div class="row text-center mt-4">
<div class="col-4"><h6>Állapot</h6><h5 class="text-success">Kiváló</h5></div>
<div class="col-4"><h6>Szerviz</h6><h5>Aktuális</h5></div>
<div class="col-4"><h6>Csapat</h6><h5>1 Sofőr</h5></div>
</div>
</div>
</div>
</div>
<div v-if="modals.reg" class="modal-overlay">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Jármű</h4>
<select class="form-select mb-3" v-model="regForm.cat" @change="regForm.brand=''; regForm.model_id=''">
<option value="" disabled>Kategória...</option>
<option v-for="(brands, cat) in meta.hierarchy" :value="cat">{{cat}}</option>
</select>
<select class="form-select mb-3" v-model="regForm.brand" :disabled="!regForm.cat">
<option value="" disabled>Márka...</option>
<option v-for="(models, brand) in meta.hierarchy[regForm.cat]" :value="brand">{{brand}}</option>
</select>
<select class="form-select mb-3" v-model="regForm.model_id" :disabled="!regForm.brand">
<option value="" disabled>Típus...</option>
<option v-for="m in meta.hierarchy[regForm.cat]?.[regForm.brand]" :value="m.id">{{m.name}}</option>
</select>
<input type="text" class="form-control mb-3" v-model="regForm.plate" placeholder="Rendszám">
<div class="d-flex gap-2">
<button class="btn btn-light w-100" @click="modals.reg=false">Mégse</button>
<button class="btn btn-primary w-100" @click="submitReg">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
authMode: 'login',
view: 'garage',
authForm: { email: '', password: '' },
regForm: { cat: '', brand: '', model_id: '', plate: '', mileage: 0 },
myCars: [],
meta: { hierarchy: {}, costTypes: {} },
modals: { reg: false },
selectedCar: null
}
},
methods: {
async handleLogin() {
const p = new URLSearchParams(); p.append('username', this.authForm.email); p.append('password', this.authForm.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: p });
if (res.ok) {
const d = await res.json();
localStorage.setItem('token', d.access_token);
this.isLoggedIn = true;
this.initApp();
} else alert("Hibás belépés!");
},
async handleRegister() {
const f = new FormData(); f.append('email', this.authForm.email); f.append('password', this.authForm.password);
const res = await fetch('/api/auth/register', { method: 'POST', body: f });
if (res.ok) { alert("Sikeres regisztráció! Most jelentkezz be."); this.authMode = 'login'; }
else alert("Hiba a regisztráció során.");
},
async initApp() {
const token = localStorage.getItem('token');
if(!token) return;
const headers = { 'Authorization': 'Bearer ' + token };
const [r1, r2, r3] = await Promise.all([
fetch('/api/my_vehicles', { headers }),
fetch('/api/meta/vehicle-hierarchy'),
fetch('/api/meta/cost-types')
]);
if(r1.ok) this.myCars = await r1.json();
if(r2.ok) this.meta.hierarchy = await r2.json();
},
async submitReg() {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('token') },
body: JSON.stringify({ ...this.regForm, vin: 'AUTO'+Math.random(), purchase_date: '2026-01-01' })
});
if(res.ok) { this.modals.reg = false; this.initApp(); }
},
logout() { localStorage.clear(); this.isLoggedIn = false; }
},
mounted() { if(this.isLoggedIn) this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
/* Detail View */
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'">Garázs</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'">Csapat</a></li>
</ul>
<div v-if="activeTab === 'garage'" class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">
{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}
</span>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'"><h3>Csapat...</h3></div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<button class="btn btn-outline-danger" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
</div>
<div class="tab-content-area">
<div class="row g-4">
<div class="col-md-3">
<div class="stat-box"><div class="stat-value">{{ selectedCar.mileage }}</div><div class="stat-label">Km</div></div>
</div>
<div class="col-md-3">
<div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'">
<div class="stat-value">
<i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i>
{{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}
</div>
<div class="stat-label">Műszaki állapot</div>
</div>
</div>
<div class="col-md-6">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Nyitott probléma:</h6>
<p class="mb-2 text-dark">{{ selectedCar.current_issue }}</p>
</div>
<button class="btn btn-sm btn-danger" @click="resolveIssue">
<i class="bi bi-wrench-adjustable me-1"></i> Megjavítva
</button>
</div>
<small class="text-muted">A javítás rögzítése naplózásra kerül.</small>
</div>
<div v-else class="alert alert-light border h-100 d-flex align-items-center justify-content-center text-muted">
<i class="bi bi-check2-all me-2"></i> Nincs nyitott hiba. Az autó útra kész.
</div>
</div>
</div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom">
<div class="modal-content-custom bg-danger bg-opacity-10 border border-danger">
<h4 class="fw-bold text-danger">Hiba bejelentése</h4>
<textarea class="form-control my-3" rows="3" v-model="errorForm.description" placeholder="Mi a gond?"></textarea>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" v-model="errorForm.is_critical">
<label class="form-check-label">Kritikus (Mozgásképtelen)</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showErrorModal = false">Mégse</button>
<button class="btn btn-danger" @click="submitError">Mentés</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return { view: 'dashboard', activeTab: 'garage', showErrorModal: false, myCars: [], selectedCar: null, errorForm: {description: '', is_critical: false} } },
methods: {
async fetchData() {
const res = await fetch('/api/my_vehicles');
this.myCars = await res.json();
},
async openVehicleDetail(id) {
const res = await fetch('/api/vehicle/' + id);
this.selectedCar = await res.json();
this.view = 'detail';
},
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() {
try {
const res = await fetch('/api/report_issue', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vehicle_id: this.selectedCar.id,
description: this.errorForm.description,
is_critical: this.errorForm.is_critical
})
});
if (res.ok) {
alert("Hiba naplózva!");
this.showErrorModal = false;
this.openVehicleDetail(this.selectedCar.id);
}
} catch(e) { alert("Hiba!"); }
},
// EZ A FÜGGVÉNY MOST MÁR TÉNYLEG JAVÍT ÉS NAPLÓZ!
async resolveIssue() {
if(!confirm("Biztosan megjavítottad az autót? A státusz visszaáll OK-ra.")) return;
try {
const res = await fetch('/api/resolve_issue', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ vehicle_id: this.selectedCar.id })
});
if (res.ok) {
alert("Siker! Az autó újra bevethető.");
this.openVehicleDetail(this.selectedCar.id); // Frissítés
}
} catch(e) { alert("Hiba történt!"); }
}
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
/* Kártyák */
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
/* Ha hiba van, a csík piros legyen */
.card-top-strip.danger { background: #dc3545; }
.car-icon { font-size: 2rem; color: #6c757d; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
/* Detail View */
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
/* Tabs */
.detail-tabs .nav-link { border: none; color: #6c757d; font-weight: 600; padding-bottom: 15px; border-bottom: 3px solid transparent; }
.detail-tabs .nav-link.active { color: #0d6efd; border-bottom-color: #0d6efd; background: none; }
.tab-content-area { background: white; border-radius: 0 0 15px 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); margin-top: -1px; }
/* Modals */
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container">
<span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="text-white small"><i class="bi bi-building me-1"></i> Demo Company Kft.</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'"><i class="bi bi-car-front-fill me-2"></i>Garázs</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'"><i class="bi bi-people-fill me-2"></i>Csapat</a></li>
</ul>
<div v-if="activeTab === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Járművek</h4>
<button class="btn btn-outline-primary btn-sm" @click="view = 'wizard'"><i class="bi bi-plus-lg"></i> Új rögzítése</button>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car)">
<div class="card-top-strip" :class="{ 'danger': car.has_issues }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0 text-dark">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.has_issues" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front car-icon"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge border" :class="car.has_issues ? 'bg-danger text-white' : 'bg-light text-dark'">
{{ car.has_issues ? 'HIBA' : translateRole(car.role) }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'">
<h3>Csapat nézet (változatlan)</h3>
<button class="btn btn-secondary btn-sm" @click="activeTab='garage'">Vissza</button>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 ps-3 pe-3 rounded-pill" @click="view = 'dashboard'">
<i class="bi bi-arrow-left me-2"></i>Garázs
</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="selectedCar.has_issues ? 'bg-danger' : 'bg-primary'"
style="width: 60px; height: 60px;">
<i class="bi" :class="selectedCar.has_issues ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.has_issues" class="badge bg-danger">HIBA JELENTVE</span>
<span v-else class="badge bg-success">ÁLLAPOT: OK</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2" @click="openErrorModal">
<i class="bi bi-exclamation-circle-fill me-1"></i> Hiba jelentése
</button>
<button class="btn btn-success"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-0">
<li class="nav-item"><a class="nav-link active" href="#">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" href="#">Szervizkönyv</a></li>
</ul>
<div class="tab-content-area">
<div class="row g-4">
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-primary">{{ selectedCar.mileage ? selectedCar.mileage.toLocaleString() : 0 }} km</div>
<div class="stat-label">Aktuális óraállás</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box" :style="selectedCar.has_issues ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'">
<div class="stat-value">
<i :class="selectedCar.has_issues ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i>
{{ selectedCar.has_issues ? 'HIBA' : 'OK' }}
</div>
<div class="stat-label">Műszaki állapot</div>
</div>
</div>
<div class="col-md-6">
<div v-if="selectedCar.has_issues" class="alert alert-danger h-100">
<h6 class="fw-bold"><i class="bi bi-exclamation-triangle me-2"></i>Nyitott probléma:</h6>
<p class="mb-0">{{ selectedCar.last_issue_text }}</p>
<div class="mt-2 text-end">
<button class="btn btn-sm btn-outline-danger bg-white" @click="resolveIssue">Megjavítva (Lezárás)</button>
</div>
</div>
<div v-else class="alert alert-light border h-100 d-flex align-items-center justify-content-center text-muted">
Nincs nyitott hiba. Az autó útra kész.
</div>
</div>
</div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom">
<div class="modal-content-custom bg-danger bg-opacity-10 border border-danger">
<h4 class="fw-bold text-danger mb-3"><i class="bi bi-exclamation-triangle-fill me-2"></i>Hiba bejelentése</h4>
<p class="small text-muted">A hiba rögzítése azonnal értesíti a flotta menedzsert.</p>
<div class="mb-3">
<label class="form-label fw-bold">Mi a probléma?</label>
<textarea class="form-control" rows="3" v-model="errorForm.description" placeholder="pl. Kigyulladt a motor lámpa, jobb első defekt..."></textarea>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="criticalCheck">
<label class="form-check-label" for="criticalCheck">Az autó mozgásképtelen</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showErrorModal = false">Mégse</button>
<button class="btn btn-danger" :disabled="!errorForm.description" @click="submitError">Bejelentés</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
view: 'dashboard',
activeTab: 'garage',
showErrorModal: false,
errorForm: { description: '' },
myCars: [],
selectedCar: null
}
},
methods: {
translateRole(role) { return role === 'OWNER' ? 'Tulajdonos' : 'Sofőr'; },
async fetchData() {
const res = await fetch('/api/my_vehicles');
const data = await res.json();
// Hozzáadunk egy kliens oldali flag-et a demóhoz (has_issues)
this.myCars = data.map(c => ({...c, has_issues: false, last_issue_text: ''}));
},
async openVehicleDetail(car) {
// Itt most a lista objektumot használjuk közvetlenül a demó kedvéért
// A valóságban itt kérnénk le az API-t
this.selectedCar = car;
this.view = 'detail';
},
// --- HIBAKEZELÉS LOGIKA (DEMO) ---
openErrorModal() {
this.errorForm.description = '';
this.showErrorModal = true;
},
submitError() {
// 1. Beállítjuk a hibát a kiválasztott autón
this.selectedCar.has_issues = true;
this.selectedCar.last_issue_text = this.errorForm.description;
// 2. Bezárjuk a modalt
this.showErrorModal = false;
alert("Hiba sikeresen rögzítve! A státusz megváltozott.");
},
resolveIssue() {
if(confirm("Biztosan lezárod a hibát? (Megjavítva)")) {
this.selectedCar.has_issues = false;
this.selectedCar.last_issue_text = '';
}
}
},
mounted() {
this.fetchData();
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Garázs</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
/* KOMPAKT CSEMPE STÍLUSOK */
.garage-card {
border: none; border-radius: 12px; background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer; height: 100%; position: relative; overflow: hidden;
}
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
/* Fejléc sáv a kártyán (Márka színe lehetne később) */
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.car-icon { font-size: 2rem; color: #6c757d; } /* Kisebb ikon */
.plate-badge {
background: #ffcc00; color: black; border: 1px solid #e0b000;
font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px;
font-size: 0.9rem; border-radius: 4px; display: inline-block; letter-spacing: 1px;
}
.role-badge { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
/* Új Hozzáadása Csempe - Kompakt */
.add-card {
border: 2px dashed #cbd5e1; background: rgba(255,255,255,0.5);
display: flex; align-items: center; justify-content: center;
color: #64748b; min-height: 160px; /* Alacsonyabb */
}
.add-card:hover { border-color: #0d6efd; color: #0d6efd; background: white; }
.wizard-card { background: white; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.05); padding: 30px; }
.step { width: 30px; height: 30px; background: #e9ecef; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-right: 10px; font-weight: bold; }
.step.active { background: #0d6efd; color: white; }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container">
<span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<span class="text-white small opacity-75">Demo User</span>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Saját Járművek</h4>
<span class="badge bg-secondary rounded-pill">{{ myCars.length }} db</span>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3">
<div class="card-top-strip"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="fw-bold mb-0 text-dark">{{ car.brand }}</h5>
<div class="text-muted small">{{ car.model }}</div>
</div>
<i v-if="car.category === 'Motor'" class="bi bi-bicycle car-icon"></i>
<i v-else class="bi bi-car-front car-icon"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge bg-light text-dark border role-badge">
{{ translateRole(car.role) }}
</span>
</div>
<div class="text-end mt-3">
<small class="text-primary fw-bold" style="cursor: pointer;">Adatlap megnyitása <i class="bi bi-chevron-right"></i></small>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="garage-card add-card" @click="switchToWizard">
<div class="text-center">
<i class="bi bi-plus-lg fs-3 mb-1"></i>
<h6 class="fw-bold mb-0">Új jármű</h6>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'wizard'" class="wizard-card">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold"><span class="step" :class="{active: step <= 3}">{{ step }}</span> Új rögzítése</h4>
<button class="btn btn-close" @click="cancelWizard"></button>
</div>
<div v-if="step === 1">
<div class="row g-3">
<div class="col-md-4"><label class="form-label small fw-bold">Kategória</label><select class="form-select" v-model="form.category"><option v-for="c in uniqueCategories" :value="c">{{ c }}</option></select></div>
<div class="col-md-4"><label class="form-label small fw-bold">Márka</label><select class="form-select" v-model="form.brand" :disabled="!form.category"><option v-for="b in filteredBrands" :value="b">{{ b }}</option></select></div>
<div class="col-md-4"><label class="form-label small fw-bold">Modell</label><select class="form-select" v-model="form.model" :disabled="!form.brand"><option v-for="m in filteredModels" :value="m">{{ m.model }}</option></select></div>
</div>
<div class="mt-4 text-end"><button class="btn btn-primary" :disabled="!form.model" @click="step++">Tovább</button></div>
</div>
<div v-if="step === 2">
<div class="alert alert-info py-2 small">Jármű: <strong>{{ form.brand }} {{ form.model.model }}</strong></div>
<div class="row g-3">
<div class="col-6"><label class="form-label small fw-bold">Alvázszám (VIN) *</label><input class="form-control text-uppercase" v-model="form.vin"></div>
<div class="col-6"><label class="form-label small fw-bold">Rendszám *</label><input class="form-control text-uppercase" v-model="form.plate"></div>
<div class="col-6"><label class="form-label small fw-bold">Km állás *</label><input type="number" class="form-control" v-model="form.mileage"></div>
<div class="col-6"><label class="form-label small fw-bold">Vásárlás dátuma</label><input type="date" class="form-control" v-model="form.purchaseDate"></div>
</div>
<div class="mt-4 d-flex justify-content-between"><button class="btn btn-secondary" @click="step--">Vissza</button><button class="btn btn-success" :disabled="!form.vin || !form.plate" @click="saveVehicle">Mentés</button></div>
</div>
<div v-if="step === 3" class="text-center py-5">
<i class="bi bi-check-circle-fill text-success display-3"></i>
<h4 class="mt-3">Sikeres rögzítés!</h4>
<button class="btn btn-primary mt-4" @click="finishWizard">Vissza a Garázsba</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
view: 'dashboard',
step: 1,
myCars: [],
catalog: [],
form: { category: "", brand: "", model: null, vin: "", plate: "", mileage: "", purchaseDate: "" }
}
},
computed: {
uniqueCategories() { return [...new Set(this.catalog.map(v => v.category).filter(c => c))].sort(); },
filteredBrands() { return this.form.category ? [...new Set(this.catalog.filter(v => v.category === this.form.category).map(v => v.brand))].sort() : []; },
filteredModels() { return this.form.brand ? this.catalog.filter(v => v.brand === this.form.brand && v.category === this.form.category).sort((a,b)=>a.model.localeCompare(b.model)) : []; }
},
methods: {
// --- FORDÍTÓ FÜGGVÉNY ---
translateRole(role) {
const map = {
'OWNER': 'Tulajdonos',
'DRIVER': 'Sofőr',
'LEASE': 'Lízingelő',
'COMPANY': 'Cég'
};
return map[role] || role; // Ha nincs a listában, marad az eredeti
},
async fetchMyGarage() {
const res = await fetch('/api/my_vehicles');
this.myCars = await res.json();
},
async fetchCatalog() {
const res = await fetch('/api/vehicles');
this.catalog = await res.json();
},
switchToWizard() {
this.step = 1;
this.form = { category: "", brand: "", model: null, vin: "", plate: "", mileage: "", purchaseDate: "" };
this.view = 'wizard';
},
cancelWizard() { this.view = 'dashboard'; },
async saveVehicle() {
const payload = {
model_id: this.form.model.id,
vin: this.form.vin,
plate: this.form.plate,
mileage: parseInt(this.form.mileage),
purchase_date: this.form.purchaseDate || new Date().toISOString().split('T')[0]
};
try {
const res = await fetch('/api/register', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
if(res.ok) { this.step = 3; }
} catch(e) { alert(e); }
},
finishWizard() {
this.fetchMyGarage();
this.view = 'dashboard';
}
},
mounted() {
this.fetchMyGarage();
this.fetchCatalog();
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Flotta Kezelő</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
/* Auth Képernyő */
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
/* Alkalmazás stílusok */
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div v-if="authView === 'login'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="authView='register'">Regisztrálj!</a></p>
</div>
<div v-if="authView === 'register'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-success w-100 py-2" @click="register">Regisztráció</button>
<p class="text-center mt-3 small"><a href="#" @click="authView='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary shadow-sm px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="d-flex align-items-center">
<span class="text-white me-3 small">{{ userEmail }}</span>
<button class="btn btn-outline-light btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold">Saját Járművek</h4>
<button class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Új jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="plate-badge">{{ car.plate }}</span>
<i v-if="car.status !== 'OK'" class="bi bi-exclamation-triangle text-danger"></i>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none ps-0 mb-3" @click="view='dashboard'"><i class="bi bi-arrow-left"></i> Vissza a listához</button>
<div class="detail-header shadow-sm border">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="fw-bold mb-1">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="text-end">
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
</div>
<div class="tab-content-area shadow-sm">
<div class="row text-center">
<div class="col-4 border-end"><h6>Km állás</h6><div class="fw-bold fs-4">{{ selectedCar.mileage }}</div></div>
<div class="col-4 border-end"><h6>Idei költség</h6><div class="fw-bold fs-4">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div>
<div class="col-4"><h6>Állapot</h6><div :class="selectedCar.status === 'OK' ? 'text-success' : 'text-danger'" class="fw-bold fs-4">{{ selectedCar.status }}</div></div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom auth-wrapper" style="position:fixed; top:0; left:0; width:100%; background:rgba(0,0,0,0.5); z-index:9999;">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Költség Rögzítése</h4>
<div class="row g-3">
<div class="col-6">
<label class="small fw-bold">Kategória</label>
<select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory">
<option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Típus</label>
<select class="form-select" v-model="costForm.subCategory">
<option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option>
</select>
</div>
<div class="col-6"><input type="number" class="form-control" v-model="costForm.amount" placeholder="Összeg"></div>
<div class="col-6"><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
</div>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-light me-2" @click="showCostModal=false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
userEmail: '',
authView: 'login',
view: 'dashboard',
authForm: { email: '', password: '' },
myCars: [],
selectedCar: null,
showCostModal: false,
costForm: { mainCategory: '', subCategory: '', amount: '', currency: 'HUF' },
costDefinitions: {}
}
},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory]?.subs || null; }
},
methods: {
// --- AUTH ---
async login() {
const params = new URLSearchParams();
params.append('username', this.authForm.email); // Az email megy a username mezőbe
params.append('password', this.authForm.password);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
this.isLoggedIn = true;
this.initApp();
} else {
const errorData = await res.json();
alert("Belépés elutasítva: " + (errorData.detail || "Ismeretlen hiba"));
}
} catch (e) {
alert("Szerver hiba a bejelentkezéskor!");
}
}
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
await fetch('/api/auth/register', { method: 'POST', body: fd });
alert("Sikeres regisztráció! Jelentkezz be.");
this.authView = 'login';
},
logout() { localStorage.removeItem('token'); this.isLoggedIn = false; },
// --- API HELPERS (JWT Token-nel!) ---
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` };
const res = await fetch(url, options);
if(res.status === 401) this.logout();
return res;
},
// --- ALKALMAZÁS LOGIKA ---
async initApp() {
if(!this.isLoggedIn) return;
const resCars = await this.apiFetch('/api/my_vehicles');
this.myCars = await resCars.json();
const resTypes = await fetch('/api/ref/cost_types'); // Ez publikus is lehet
this.costDefinitions = await resTypes.json();
},
async openVehicleDetail(id) {
const res = await this.apiFetch(`/api/vehicle/${id}`);
this.selectedCar = await res.json();
this.view = 'detail';
},
openCostModal() {
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory]?.subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.subCategory || this.costForm.mainCategory);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.costForm.currency);
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const res = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(res.ok) {
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
}
},
formatMoney(amount, curr) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: curr }).format(amount); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
/* Navigáció */
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
/* Csempék */
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.car-icon { font-size: 2rem; color: #6c757d; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
/* DETAIL VIEW (Adatlap) */
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
/* Detail Tabs */
.detail-tabs .nav-link { border: none; color: #6c757d; font-weight: 600; padding-bottom: 15px; border-bottom: 3px solid transparent; }
.detail-tabs .nav-link.active { color: #0d6efd; border-bottom-color: #0d6efd; background: none; }
.tab-content-area { background: white; border-radius: 0 0 15px 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); margin-top: -1px; }
/* Csapat táblázat */
.avatar-circle { width: 40px; height: 40px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container">
<span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="text-white small"><i class="bi bi-building me-1"></i> Demo Company Kft.</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'"><i class="bi bi-car-front-fill me-2"></i>Garázs</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'"><i class="bi bi-people-fill me-2"></i>Csapat</a></li>
</ul>
<div v-if="activeTab === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Járművek</h4>
<button class="btn btn-outline-primary btn-sm" @click="switchToWizard"><i class="bi bi-plus-lg"></i> Új rögzítése</button>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0 text-dark">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i class="bi bi-car-front car-icon"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge bg-light text-dark border">{{ translateRole(car.role) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Munkatársak</h4>
<button class="btn btn-primary btn-sm" @click="showInviteModal = true"><i class="bi bi-person-plus-fill me-2"></i>Új meghívása</button>
</div>
<div class="card border-0 shadow-sm rounded-4 p-4 text-center text-muted" v-if="team.length === 0">Nincs adat</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden" v-else>
<table class="table table-hover align-middle mb-0">
<thead class="bg-light"><tr><th class="ps-4">Név</th><th>Szerepkör</th><th>Ország</th><th>Csatlakozott</th></tr></thead>
<tbody>
<tr v-for="member in team">
<td class="ps-4"><div class="fw-bold">Munkatárs</div><div class="small text-muted">{{member.email}}</div></td>
<td>{{translateRole(member.role)}}</td>
<td>{{member.country}}</td>
<td>{{member.joined_at ? member.joined_at.split('T')[0] : ''}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none mb-3 ps-0" @click="view = 'dashboard'">
<i class="bi bi-arrow-left me-1"></i> Vissza a Garázsba
</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 60px; height: 60px;">
<i class="bi bi-car-front fs-2"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span class="badge bg-secondary">{{ translateRole(selectedCar.role) }}</span>
<span class="text-muted small ms-2"><i class="bi bi-upc-scan me-1"></i>{{ selectedCar.vin }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2"><i class="bi bi-exclamation-triangle"></i> Hiba jelentése</button>
<button class="btn btn-success"><i class="bi bi-plus-lg"></i> Költség / Szerviz</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-0">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'overview'}" href="#" @click="detailTab = 'overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'history'}" href="#" @click="detailTab = 'history'">Szervizkönyv</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'settings'}" href="#" @click="detailTab = 'settings'">Beállítások</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-4">
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-primary">{{ selectedCar.mileage.toLocaleString() }} km</div>
<div class="stat-label">Aktuális óraállás</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-success">OK</div>
<div class="stat-label">Műszaki állapot</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value">{{ formatCurrency(0, selectedCar.currency) }}</div>
<div class="stat-label">Idei költés</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-muted">{{ selectedCar.start_date }}</div>
<div class="stat-label">Flottába került</div>
</div>
</div>
</div>
<h5 class="fw-bold mt-5 mb-3">Legutóbbi aktivitás</h5>
<div class="alert alert-light border text-center text-muted py-4">
<i class="bi bi-clock-history fs-3 d-block mb-2"></i>
Még nincs rögzített esemény.
</div>
</div>
<div v-if="detailTab === 'history'">
<div class="text-center py-5 text-muted">
<i class="bi bi-tools display-4 mb-3"></i>
<h5>A szerviztörténet üres</h5>
<p>Rögzíts tankolást vagy szervizt a jobb felső gombbal!</p>
</div>
</div>
<div v-if="detailTab === 'settings'">
<h5 class="text-danger fw-bold">Veszélyzóna</h5>
<hr>
<p>Jármű eltávolítása a flottából vagy eladás.</p>
<button class="btn btn-outline-danger">Jármű archiválása</button>
</div>
</div>
</div>
<div v-if="showInviteModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4>Meghívás</h4>
<input class="form-control my-3" v-model="inviteForm.email" placeholder="Email cím">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showInviteModal = false">Mégse</button>
<button class="btn btn-primary" @click="sendInvite">Küldés</button>
</div>
</div>
</div>
<div v-if="view === 'wizard'" class="wizard-card"><button @click="view='dashboard'">Mégse</button> <h3>Varázsló...</h3></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
view: 'dashboard', // dashboard | detail | wizard
activeTab: 'garage', // garage | team
detailTab: 'overview',// overview | history | settings
showInviteModal: false,
inviteForm: { email: '', role: 'DRIVER', access_level: 'LOG_ONLY' },
myCars: [],
team: [],
selectedCar: null // Ide töltjük be a részleteket
}
},
methods: {
translateRole(role) {
const map = { 'OWNER': 'Tulajdonos', 'DRIVER': 'Sofőr', 'FLEET_MANAGER': 'Flotta Menedzser' };
return map[role] || role;
},
formatCurrency(amount, currency) {
// Ez a "Varázsló", ami a böngésző nyelvétől függően formáz
try {
return new Intl.NumberFormat(navigator.language, { style: 'currency', currency: currency || 'HUF' }).format(amount);
} catch (e) { return amount + " " + currency; }
},
async fetchData() {
const res1 = await fetch('/api/my_vehicles');
this.myCars = await res1.json();
const res2 = await fetch('/api/fleet/members');
this.team = await res2.json();
},
async openVehicleDetail(id) {
// Lekérjük a részletes adatokat
try {
const res = await fetch('/api/vehicle/' + id);
if(res.ok) {
this.selectedCar = await res.json();
this.view = 'detail';
this.detailTab = 'overview';
} else { alert("Hiba az adatok betöltésekor"); }
} catch(e) { console.error(e); }
},
async sendInvite() { /* ... (előző kód) ... */ alert("Meghívó elküldve!"); this.showInviteModal = false; },
switchToWizard() { this.view = 'wizard'; }
},
mounted() {
this.fetchData();
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Flotta Kezelő</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
/* Auth Képernyő */
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
/* Alkalmazás stílusok */
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div v-if="authView === 'login'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="authView='register'">Regisztrálj!</a></p>
</div>
<div v-if="authView === 'register'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-success w-100 py-2" @click="register">Regisztráció</button>
<p class="text-center mt-3 small"><a href="#" @click="authView='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary shadow-sm px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="d-flex align-items-center">
<span class="text-white me-3 small">{{ userEmail }}</span>
<button class="btn btn-outline-light btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold">Saját Járművek</h4>
<button class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Új jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="plate-badge">{{ car.plate }}</span>
<i v-if="car.status !== 'OK'" class="bi bi-exclamation-triangle text-danger"></i>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none ps-0 mb-3" @click="view='dashboard'"><i class="bi bi-arrow-left"></i> Vissza a listához</button>
<div class="detail-header shadow-sm border">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="fw-bold mb-1">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="text-end">
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
</div>
<div class="tab-content-area shadow-sm">
<div class="row text-center">
<div class="col-4 border-end"><h6>Km állás</h6><div class="fw-bold fs-4">{{ selectedCar.mileage }}</div></div>
<div class="col-4 border-end"><h6>Idei költség</h6><div class="fw-bold fs-4">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div>
<div class="col-4"><h6>Állapot</h6><div :class="selectedCar.status === 'OK' ? 'text-success' : 'text-danger'" class="fw-bold fs-4">{{ selectedCar.status }}</div></div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom auth-wrapper" style="position:fixed; top:0; left:0; width:100%; background:rgba(0,0,0,0.5); z-index:9999;">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Költség Rögzítése</h4>
<div class="row g-3">
<div class="col-6">
<label class="small fw-bold">Kategória</label>
<select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory">
<option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Típus</label>
<select class="form-select" v-model="costForm.subCategory">
<option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option>
</select>
</div>
<div class="col-6"><input type="number" class="form-control" v-model="costForm.amount" placeholder="Összeg"></div>
<div class="col-6"><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
</div>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-light me-2" @click="showCostModal=false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
userEmail: '',
authView: 'login',
view: 'dashboard',
authForm: { email: '', password: '' },
myCars: [],
selectedCar: null,
showCostModal: false,
costForm: { mainCategory: '', subCategory: '', amount: '', currency: 'HUF' },
costDefinitions: {}
}
},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory]?.subs || null; }
},
methods: {
// --- AUTH ---
async login() {
const fd = new FormData();
fd.append('username', this.authForm.email);
fd.append('password', this.authForm.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: fd });
if(res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
this.isLoggedIn = true;
this.initApp();
} else { alert("Belépés elutasítva!"); }
},
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
await fetch('/api/auth/register', { method: 'POST', body: fd });
alert("Sikeres regisztráció! Jelentkezz be.");
this.authView = 'login';
},
logout() { localStorage.removeItem('token'); this.isLoggedIn = false; },
// --- API HELPERS (JWT Token-nel!) ---
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` };
const res = await fetch(url, options);
if(res.status === 401) this.logout();
return res;
},
// --- ALKALMAZÁS LOGIKA ---
async initApp() {
if(!this.isLoggedIn) return;
const resCars = await this.apiFetch('/api/my_vehicles');
this.myCars = await resCars.json();
const resTypes = await fetch('/api/ref/cost_types'); // Ez publikus is lehet
this.costDefinitions = await resTypes.json();
},
async openVehicleDetail(id) {
const res = await this.apiFetch(`/api/vehicle/${id}`);
this.selectedCar = await res.json();
this.view = 'detail';
},
openCostModal() {
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory]?.subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.subCategory || this.costForm.mainCategory);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.costForm.currency);
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const res = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(res.ok) {
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
}
},
formatMoney(amount, curr) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: curr }).format(amount); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div class="mb-3">
<input type="email" class="form-control" v-model="authForm.email" placeholder="Email">
</div>
<div class="mb-3">
<input type="password" class="form-control" v-model="authForm.password" placeholder="Jelszó">
</div>
<button class="btn btn-primary w-100" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="register">Regisztráció</a></p>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold">Service Finder</span>
<button class="btn btn-outline-light btn-sm" @click="logout">Kijelentkezés</button>
</div>
</nav>
<div class="container main-container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold">Garázs</h4>
<div class="text-muted">Üdv: {{ userEmail }}</div>
</div>
<div v-if="myCars.length === 0" class="alert alert-info border-0 shadow-sm rounded-4 p-4 text-center">
<i class="bi bi-info-circle fs-2 d-block mb-2"></i>
Még nincsenek járművek a profilodhoz rendelve.
<br>Használd a pgAdmint vagy az API-t a járművek hozzárendeléséhez az <b>ID: {{ userId }}</b> felhasználóhoz.
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3">
<div class="card-top-strip"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<span class="plate-badge">{{ car.plate }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
userId: localStorage.getItem('userId') || '',
userEmail: localStorage.getItem('userEmail') || '',
authForm: { email: '', password: '' },
myCars: []
}
},
methods: {
async login() {
const params = new URLSearchParams();
params.append('username', this.authForm.email);
params.append('password', this.authForm.password);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('userEmail', this.authForm.email);
// Token dekódolása az ID kinyeréséhez (opcionális, de hasznos)
const payload = JSON.parse(atob(data.access_token.split('.')[1]));
localStorage.setItem('userId', payload.sub);
this.userId = payload.sub;
this.userEmail = this.authForm.email;
this.isLoggedIn = true;
this.initApp();
} else { alert("Hibás belépés!"); }
} catch (e) { alert("Szerver hiba!"); }
},
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
const res = await fetch('/api/auth/register', { method: 'POST', body: fd });
if(res.ok) alert("Sikeres regisztráció! Most jelentkezz be.");
else alert("Regisztrációs hiba!");
},
logout() {
localStorage.clear();
this.isLoggedIn = false;
},
async initApp() {
if(!this.isLoggedIn) return;
const token = localStorage.getItem('token');
try {
const res = await fetch('/api/my_vehicles', {
headers: { 'Authorization': 'Bearer ' + token }
});
if(res.ok) {
this.myCars = await res.json();
} else if(res.status === 401) {
this.logout();
}
} catch(e) { console.error("Hiba az adatok betöltésekor"); }
}
},
mounted() { if(this.isLoggedIn) this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Profi Flotta</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.tab-content-area { background: white; border-radius: 15px; padding: 25px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="text-center text-primary mb-4 fw-bold">Service Finder</h2>
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100" @click="login">Belépés</button>
<p class="text-center mt-3 small"><a href="#" @click="register">Regisztráció</a></p>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary px-4 py-2 shadow-sm">
<div class="container">
<span class="navbar-brand fw-bold" @click="view='dashboard'; initApp()" style="cursor:pointer">Service Finder</span>
<button class="btn btn-outline-light btn-sm" @click="logout">Kijelentkezés</button>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold">Garázsom</h4>
<button class="btn btn-success" @click="showRegisterModal=true"><i class="bi bi-plus-lg"></i> Új Jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<span class="plate-badge">{{ car.plate }}</span>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link ps-0 mb-3 text-decoration-none" @click="view='dashboard'"><i class="bi bi-arrow-left"></i> Vissza</button>
<div class="tab-content-area mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-danger btn-sm" @click="showErrorModal=true">Hiba Jelentése</button>
<button class="btn btn-success btn-sm" @click="openCostModal">Költség +</button>
</div>
</div>
<div class="row g-2 text-center">
<div class="col-4 border-end"><h6>Km állás</h6><div class="fw-bold">{{ selectedCar.mileage }}</div></div>
<div class="col-4 border-end"><h6>Státusz</h6><div class="fw-bold" :class="selectedCar.status==='OK'?'text-success':'text-danger'">{{ selectedCar.status }}</div></div>
<div class="col-4"><h6>Idei költség</h6><div class="fw-bold">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showRegisterModal" class="modal-backdrop-custom">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Jármű Regisztráció</h4>
<div class="mb-3">
<label class="small fw-bold">Modell kiválasztása</label>
<select class="form-select" v-model="regForm.model_id">
<option v-for="m in allModels" :value="m.id">{{ m.brand }} {{ m.model }}</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-6"><input type="text" class="form-control" placeholder="Rendszám" v-model="regForm.plate"></div>
<div class="col-6"><input type="text" class="form-control" placeholder="Alvázszám (utolsó 6)" v-model="regForm.vin"></div>
</div>
<div class="row g-2 mb-3">
<div class="col-6"><input type="number" class="form-control" placeholder="Km óra" v-model="regForm.mileage"></div>
<div class="col-6"><input type="date" class="form-control" v-model="regForm.purchase_date"></div>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showRegisterModal=false">Mégse</button>
<button class="btn btn-primary" @click="submitRegister">Mentés</button>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Költség rögzítése</h4>
<select class="form-select mb-3" v-model="costForm.type">
<option value="FUEL">Üzemanyag</option>
<option value="SERVICE_REPAIR">Szerviz/Javítás</option>
<option value="TAX_WEIGHT">Súlyadó</option>
<option value="INSURANCE_KGFB">Biztosítás (KGFB)</option>
</select>
<input type="number" class="form-control mb-3" placeholder="Összeg" v-model="costForm.amount">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showCostModal=false">Bezár</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
view: 'dashboard',
authForm: { email: '', password: '' },
regForm: { model_id: '', plate: '', vin: '', mileage: '', purchase_date: '' },
costForm: { type: 'FUEL', amount: '' },
myCars: [],
allModels: [],
selectedCar: null,
showRegisterModal: false,
showCostModal: false,
showErrorModal: false
}
},
methods: {
async login() {
const p = new URLSearchParams(); p.append('username', this.authForm.email); p.append('password', this.authForm.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: p });
if (res.ok) { const d = await res.json(); localStorage.setItem('token', d.access_token); this.isLoggedIn = true; this.initApp(); }
},
async register() {
const f = new FormData(); f.append('email', this.authForm.email); f.append('password', this.authForm.password);
await fetch('/api/auth/register', { method: 'POST', body: f }); alert("Sikeres regisztráció!");
},
logout() { localStorage.clear(); this.isLoggedIn = false; },
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': 'Bearer ' + token };
return await fetch(url, options);
},
async initApp() {
if(!this.isLoggedIn) return;
const r1 = await this.apiFetch('/api/my_vehicles'); if(r1.ok) this.myCars = await r1.json();
const r2 = await fetch('/api/vehicles'); if(r2.ok) this.allModels = await r2.json();
},
async openVehicleDetail(id) {
const r = await this.apiFetch('/api/vehicle/' + id);
if(r.ok) { this.selectedCar = await r.json(); this.view = 'detail'; }
},
async submitRegister() {
const r = await this.apiFetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.regForm)
});
if(r.ok) { this.showRegisterModal = false; this.initApp(); }
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.type);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.selectedCar.currency || 'HUF');
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const r = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(r.ok) { this.showCostModal = false; this.openVehicleDetail(this.selectedCar.id); }
},
formatMoney(a, c) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: c || 'HUF' }).format(a || 0); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,280 @@
Senior PM-ként mondom: Vettem az adást! 📡 A motor (Backend) már duruzsol, most építsük rá a műszerfalat (Frontend).
Ez a frissítés a rendszer leglátványosabb része lesz. Amikor rákattintasz egy autóra a Garázsban, az nem csak egy sima ablakot nyit meg, hanem egy teljes Jármű Irányítópultot, három fő füllel:
Áttekintés (Overview): A legfontosabb adatok (Rendszám, Alvázszám, Aktuális km).
Szervizkönyv (History): (Egyelőre üres, de ide jönnek majd a bejegyzések).
Beállítások: Itt lehet majd sofőrt cserélni vagy eladni az autót.
🎨 Frontend 7.0 A Részletes Adatlap
Másold be ezt a kódot. Figyeld meg a formatCurrency függvényt a script alján! Ez már felkészült arra, hogy ha a felhasználó német (EUR), akkor úgy írja ki az összegeket, ha magyar (HUF), akkor meg úgy.
Bash
echo "--- 🖥️ Frontend 7.0: Jármű Részletes Adatlap (Detail View) ---"
cat <<EOF > frontend/index.html
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
/* Navigáció */
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
/* Csempék */
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.car-icon { font-size: 2rem; color: #6c757d; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
/* DETAIL VIEW (Adatlap) */
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
/* Detail Tabs */
.detail-tabs .nav-link { border: none; color: #6c757d; font-weight: 600; padding-bottom: 15px; border-bottom: 3px solid transparent; }
.detail-tabs .nav-link.active { color: #0d6efd; border-bottom-color: #0d6efd; background: none; }
.tab-content-area { background: white; border-radius: 0 0 15px 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); margin-top: -1px; }
/* Csapat táblázat */
.avatar-circle { width: 40px; height: 40px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container">
<span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="text-white small"><i class="bi bi-building me-1"></i> Demo Company Kft.</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'"><i class="bi bi-car-front-fill me-2"></i>Garázs</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'"><i class="bi bi-people-fill me-2"></i>Csapat</a></li>
</ul>
<div v-if="activeTab === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Járművek</h4>
<button class="btn btn-outline-primary btn-sm" @click="switchToWizard"><i class="bi bi-plus-lg"></i> Új rögzítése</button>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0 text-dark">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i class="bi bi-car-front car-icon"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge bg-light text-dark border">{{ translateRole(car.role) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Munkatársak</h4>
<button class="btn btn-primary btn-sm" @click="showInviteModal = true"><i class="bi bi-person-plus-fill me-2"></i>Új meghívása</button>
</div>
<div class="card border-0 shadow-sm rounded-4 p-4 text-center text-muted" v-if="team.length === 0">Nincs adat</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden" v-else>
<table class="table table-hover align-middle mb-0">
<thead class="bg-light"><tr><th class="ps-4">Név</th><th>Szerepkör</th><th>Ország</th><th>Csatlakozott</th></tr></thead>
<tbody>
<tr v-for="member in team">
<td class="ps-4"><div class="fw-bold">Munkatárs</div><div class="small text-muted">{{member.email}}</div></td>
<td>{{translateRole(member.role)}}</td>
<td>{{member.country}}</td>
<td>{{member.joined_at ? member.joined_at.split('T')[0] : ''}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none mb-3 ps-0" @click="view = 'dashboard'">
<i class="bi bi-arrow-left me-1"></i> Vissza a Garázsba
</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 60px; height: 60px;">
<i class="bi bi-car-front fs-2"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span class="badge bg-secondary">{{ translateRole(selectedCar.role) }}</span>
<span class="text-muted small ms-2"><i class="bi bi-upc-scan me-1"></i>{{ selectedCar.vin }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2"><i class="bi bi-exclamation-triangle"></i> Hiba jelentése</button>
<button class="btn btn-success"><i class="bi bi-plus-lg"></i> Költség / Szerviz</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-0">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'overview'}" href="#" @click="detailTab = 'overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'history'}" href="#" @click="detailTab = 'history'">Szervizkönyv</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab === 'settings'}" href="#" @click="detailTab = 'settings'">Beállítások</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-4">
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-primary">{{ selectedCar.mileage.toLocaleString() }} km</div>
<div class="stat-label">Aktuális óraállás</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-success">OK</div>
<div class="stat-label">Műszaki állapot</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value">{{ formatCurrency(0, selectedCar.currency) }}</div>
<div class="stat-label">Idei költés</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value text-muted">{{ selectedCar.start_date }}</div>
<div class="stat-label">Flottába került</div>
</div>
</div>
</div>
<h5 class="fw-bold mt-5 mb-3">Legutóbbi aktivitás</h5>
<div class="alert alert-light border text-center text-muted py-4">
<i class="bi bi-clock-history fs-3 d-block mb-2"></i>
Még nincs rögzített esemény.
</div>
</div>
<div v-if="detailTab === 'history'">
<div class="text-center py-5 text-muted">
<i class="bi bi-tools display-4 mb-3"></i>
<h5>A szerviztörténet üres</h5>
<p>Rögzíts tankolást vagy szervizt a jobb felső gombbal!</p>
</div>
</div>
<div v-if="detailTab === 'settings'">
<h5 class="text-danger fw-bold">Veszélyzóna</h5>
<hr>
<p>Jármű eltávolítása a flottából vagy eladás.</p>
<button class="btn btn-outline-danger">Jármű archiválása</button>
</div>
</div>
</div>
<div v-if="showInviteModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4>Meghívás</h4>
<input class="form-control my-3" v-model="inviteForm.email" placeholder="Email cím">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showInviteModal = false">Mégse</button>
<button class="btn btn-primary" @click="sendInvite">Küldés</button>
</div>
</div>
</div>
<div v-if="view === 'wizard'" class="wizard-card"><button @click="view='dashboard'">Mégse</button> <h3>Varázsló...</h3></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
view: 'dashboard', // dashboard | detail | wizard
activeTab: 'garage', // garage | team
detailTab: 'overview',// overview | history | settings
showInviteModal: false,
inviteForm: { email: '', role: 'DRIVER', access_level: 'LOG_ONLY' },
myCars: [],
team: [],
selectedCar: null // Ide töltjük be a részleteket
}
},
methods: {
translateRole(role) {
const map = { 'OWNER': 'Tulajdonos', 'DRIVER': 'Sofőr', 'FLEET_MANAGER': 'Flotta Menedzser' };
return map[role] || role;
},
formatCurrency(amount, currency) {
// Ez a "Varázsló", ami a böngésző nyelvétől függően formáz
try {
return new Intl.NumberFormat(navigator.language, { style: 'currency', currency: currency || 'HUF' }).format(amount);
} catch (e) { return amount + " " + currency; }
},
async fetchData() {
const res1 = await fetch('/api/my_vehicles');
this.myCars = await res1.json();
const res2 = await fetch('/api/fleet/members');
this.team = await res2.json();
},
async openVehicleDetail(id) {
// Lekérjük a részletes adatokat
try {
const res = await fetch('/api/vehicle/' + id);
if(res.ok) {
this.selectedCar = await res.json();
this.view = 'detail';
this.detailTab = 'overview';
} else { alert("Hiba az adatok betöltésekor"); }
} catch(e) { console.error(e); }
},
async sendInvite() { /* ... (előző kód) ... */ alert("Meghívó elküldve!"); this.showInviteModal = false; },
switchToWizard() { this.view = 'wizard'; }
},
mounted() {
this.fetchData();
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; display: flex; flex-direction: column; justify-content: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; margin-top: 5px; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #0d6efd; }
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; }
.history-icon.FUEL { border-color: #198754; background: #198754; }
.history-icon.ISSUE_REPORT { border-color: #dc3545; background: #dc3545; }
.history-icon.ISSUE_RESOLVED { border-color: #0d6efd; background: #0d6efd; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4"><li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li></ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-3">
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div></div>
<div class="col-md-3"><div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'"><div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div><div class="stat-label">Állapot</div></div></div>
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div><div class="stat-label">Idei költés</div></div></div>
<div class="col-md-3">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger p-2 m-0 d-flex flex-column justify-content-center">
<h6 class="fw-bold text-danger mb-1" style="font-size: 0.9rem">Hiba:</h6><p class="mb-2 text-dark small" style="line-height: 1.2">{{ selectedCar.current_issue }}</p><button class="btn btn-sm btn-danger w-100" @click="resolveIssue">Megjavítva</button>
</div>
<div v-else class="stat-box text-muted border bg-light"><div class="fs-4"><i class="bi bi-shield-check"></i></div><div class="stat-label">Nincs hiba</div></div>
</div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">Még üres a szervizkönyv.</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">
{{ translateType(item.type) }}
<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span>
</h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<div class="d-flex align-items-center gap-2 text-muted small mt-1">
<span v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</span>
<a v-if="item.document_url" :href="item.document_url" target="_blank" class="badge bg-primary text-decoration-none">
<i class="bi bi-paperclip me-1"></i>Csatolmány
</a>
</div>
</div>
<div class="text-end"><span class="badge bg-light text-dark border">{{ item.date }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-12"><label class="form-label fw-bold">Típus</label><select class="form-select" v-model="costForm.type"><option value="FUEL">⛽ Tankolás</option><option value="SERVICE">🔧 Szerviz</option><option value="INSURANCE">📄 Biztosítás</option><option value="TAX">🏛️ Adó</option><option value="OTHER">Egyéb</option></select></div>
<div class="col-7"><label class="form-label fw-bold">Összeg</label><input type="number" class="form-control" v-model="costForm.amount"></div>
<div class="col-5"><label class="form-label fw-bold">Pénznem</label><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
<div class="col-6"><label class="form-label fw-bold">Km</label><input type="number" class="form-control" v-model="costForm.mileage"></div>
<div class="col-6"><label class="form-label fw-bold">Dátum</label><input type="date" class="form-control" v-model="costForm.date"></div>
<div class="col-12"><label class="form-label">Megjegyzés</label><textarea class="form-control" rows="2" v-model="costForm.description"></textarea></div>
<div class="col-12">
<label class="form-label fw-bold"><i class="bi bi-paperclip me-1"></i>Bizonylat / Számla (NAS)</label>
<input type="file" class="form-control" ref="fileInput">
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4"><button class="btn btn-light" @click="showCostModal = false">Mégse</button><button class="btn btn-success" @click="submitCost">Mentés</button></div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom"><div class="modal-content-custom bg-danger bg-opacity-10 border border-danger"><h4 class="fw-bold text-danger">Hiba</h4><textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea><div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div><div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div></div></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return { view: 'dashboard', detailTab: 'overview', showErrorModal: false, showCostModal: false, myCars: [], selectedCar: null, history: [], errorForm: {description: '', is_critical: false}, costForm: { type: 'FUEL', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' } }},
methods: {
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) { const res = await fetch('/api/vehicle/' + id); this.selectedCar = await res.json(); this.view = 'detail'; this.detailTab = 'overview'; },
openCostModal() { this.costForm = { type: 'FUEL', amount: '', currency: this.selectedCar.currency || 'HUF', mileage: this.selectedCar.mileage, date: new Date().toISOString().split('T')[0], description: '' }; this.showCostModal = true; },
// --- MÓDOSÍTOTT SUBMIT: FORM DATA ---
async submitCost() {
const formData = new FormData();
formData.append('vehicle_id', this.selectedCar.id);
formData.append('cost_type', this.costForm.type);
formData.append('amount', this.costForm.amount);
formData.append('currency', this.costForm.currency);
formData.append('mileage', this.costForm.mileage);
formData.append('date_str', this.costForm.date);
formData.append('description', this.costForm.description);
// Fájl hozzáadása, ha van
const fileInput = this.$refs.fileInput;
if(fileInput.files.length > 0) {
formData.append('file', fileInput.files[0]);
}
try {
const res = await fetch('/api/add_cost', {
method: 'POST',
body: formData // Nem JSON, hanem FormData!
});
if (res.ok) {
alert("Költség és fájl rögzítve!");
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
if(this.detailTab === 'history') this.loadHistory();
}
} catch(e) { alert("Hiba a feltöltéskor!"); }
},
async loadHistory() { this.detailTab = 'history'; const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history'); this.history = await res.json(); },
formatMoney(amount, currency) { try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); } catch(e) { return amount + " " + currency; } },
translateType(type) { const map = { 'FUEL': 'Tankolás', 'SERVICE': 'Szerviz', 'INSURANCE': 'Biztosítás', 'TAX': 'Adó', 'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció' }; return map[type] || type; },
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() { await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) }); this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id); },
async resolveIssue() { if(!confirm("Megjavítva?")) return; await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) }); this.openVehicleDetail(this.selectedCar.id); }
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Flotta Kezelő</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
/* Auth Képernyő */
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
/* Alkalmazás stílusok */
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div v-if="authView === 'login'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="authView='register'">Regisztrálj!</a></p>
</div>
<div v-if="authView === 'register'">
<input type="email" class="form-control mb-3" v-model="authForm.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="authForm.password" placeholder="Jelszó">
<button class="btn btn-success w-100 py-2" @click="register">Regisztráció</button>
<p class="text-center mt-3 small"><a href="#" @click="authView='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary shadow-sm px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="d-flex align-items-center">
<span class="text-white me-3 small">{{ userEmail }}</span>
<button class="btn btn-outline-light btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold">Saját Járművek</h4>
<button class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Új jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="plate-badge">{{ car.plate }}</span>
<i v-if="car.status !== 'OK'" class="bi bi-exclamation-triangle text-danger"></i>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none ps-0 mb-3" @click="view='dashboard'"><i class="bi bi-arrow-left"></i> Vissza a listához</button>
<div class="detail-header shadow-sm border">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="fw-bold mb-1">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="text-end">
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
</div>
<div class="tab-content-area shadow-sm">
<div class="row text-center">
<div class="col-4 border-end"><h6>Km állás</h6><div class="fw-bold fs-4">{{ selectedCar.mileage }}</div></div>
<div class="col-4 border-end"><h6>Idei költség</h6><div class="fw-bold fs-4">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div>
<div class="col-4"><h6>Állapot</h6><div :class="selectedCar.status === 'OK' ? 'text-success' : 'text-danger'" class="fw-bold fs-4">{{ selectedCar.status }}</div></div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom auth-wrapper" style="position:fixed; top:0; left:0; width:100%; background:rgba(0,0,0,0.5); z-index:9999;">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Költség Rögzítése</h4>
<div class="row g-3">
<div class="col-6">
<label class="small fw-bold">Kategória</label>
<select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory">
<option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Típus</label>
<select class="form-select" v-model="costForm.subCategory">
<option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option>
</select>
</div>
<div class="col-6"><input type="number" class="form-control" v-model="costForm.amount" placeholder="Összeg"></div>
<div class="col-6"><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
</div>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-light me-2" @click="showCostModal=false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
userEmail: '',
authView: 'login',
view: 'dashboard',
authForm: { email: '', password: '' },
myCars: [],
selectedCar: null,
showCostModal: false,
costForm: { mainCategory: '', subCategory: '', amount: '', currency: 'HUF' },
costDefinitions: {}
}
},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory]?.subs || null; }
},
methods: {
// --- AUTH ---
async login() {
const fd = new FormData();
fd.append('username', this.authForm.email);
fd.append('password', this.authForm.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: fd });
if(res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
this.isLoggedIn = true;
this.initApp();
} else { alert("Belépés elutasítva!"); }
},
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
await fetch('/api/auth/register', { method: 'POST', body: fd });
alert("Sikeres regisztráció! Jelentkezz be.");
this.authView = 'login';
},
logout() { localStorage.removeItem('token'); this.isLoggedIn = false; },
// --- API HELPERS (JWT Token-nel!) ---
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` };
const res = await fetch(url, options);
if(res.status === 401) this.logout();
return res;
},
// --- ALKALMAZÁS LOGIKA ---
async initApp() {
if(!this.isLoggedIn) return;
const resCars = await this.apiFetch('/api/my_vehicles');
this.myCars = await resCars.json();
const resTypes = await fetch('/api/ref/cost_types'); // Ez publikus is lehet
this.costDefinitions = await resTypes.json();
},
async openVehicleDetail(id) {
const res = await this.apiFetch(`/api/vehicle/${id}`);
this.selectedCar = await res.json();
this.view = 'detail';
},
openCostModal() {
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory]?.subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.subCategory || this.costForm.mainCategory);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.costForm.currency);
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const res = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(res.ok) {
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id);
}
},
formatMoney(amount, curr) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: curr }).format(amount); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; display: flex; flex-direction: column; justify-content: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; margin-top: 5px; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
/* ÚJ IKON SZÍNEK */
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #6c757d; }
.history-icon.FUEL { border-color: #198754; background: #198754; } /* Zöld */
.history-icon.PURCHASE { border-color: #0d6efd; background: #0d6efd; } /* Kék */
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; } /* Narancs */
.history-icon.INSURANCE { border-color: #6610f2; background: #6610f2; } /* Lila */
.history-icon.TAX { border-color: #dc3545; background: #dc3545; } /* Piros */
.history-icon.EQUIPMENT { border-color: #20c997; background: #20c997; } /* Türkiz (Felszerelés) */
.history-icon.OPERATIONAL { border-color: #6f42c1; background: #6f42c1; }/* Sötétlila (Üzemeltetés) */
.history-icon.FINE { border-color: #212529; background: #212529; } /* Fekete (Bírság) */
.history-icon.ISSUE_REPORT { border-color: #dc3545; background: #white; border-width: 4px; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4"><li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li></ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end"><div class="plate-badge">{{ car.plate }}</div><span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}</span></div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div><h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2><div class="d-flex align-items-center mt-1 gap-2"><span class="plate-badge fs-6">{{ selectedCar.plate }}</span><span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span></div></div>
</div>
<div class="text-end"><button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button><button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség</button></div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3"><li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li><li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li></ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-3">
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div></div>
<div class="col-md-3"><div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'"><div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div><div class="stat-label">Állapot</div></div></div>
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div><div class="stat-label">Idei költés</div></div></div>
<div class="col-md-3">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger p-2 m-0 d-flex flex-column justify-content-center"><h6 class="fw-bold text-danger mb-1" style="font-size: 0.9rem">Hiba:</h6><p class="mb-2 text-dark small" style="line-height: 1.2">{{ selectedCar.current_issue }}</p><button class="btn btn-sm btn-danger w-100" @click="resolveIssue">Megjavítva</button></div><div v-else class="stat-box text-muted border bg-light"><div class="fs-4"><i class="bi bi-shield-check"></i></div><div class="stat-label">Nincs hiba</div></div>
</div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">Még üres a szervizkönyv.</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type.split('_')[0]"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">{{ translateType(item.type) }}<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span></h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<div class="d-flex align-items-center gap-2 text-muted small mt-1"><span v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</span><a v-if="item.document_url" :href="item.document_url" target="_blank" class="badge bg-primary text-decoration-none"><i class="bi bi-paperclip me-1"></i>Csatolmány</a></div>
</div>
<div class="text-end"><span class="badge bg-light text-dark border">{{ item.date }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-6"><label class="form-label fw-bold small">Kategória</label><select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory"><option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option></select></div>
<div class="col-6"><label class="form-label fw-bold small">Típus</label><select class="form-select" v-model="costForm.subCategory" :disabled="!currentSubOptions"><option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option></select></div>
<div class="col-7"><label class="form-label fw-bold small">Összeg</label><input type="number" class="form-control" v-model="costForm.amount"></div>
<div class="col-5"><label class="form-label fw-bold small">Pénznem</label><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
<div class="col-6"><label class="form-label fw-bold small">Km állás</label><input type="number" class="form-control" v-model="costForm.mileage"></div>
<div class="col-6"><label class="form-label fw-bold small">Dátum</label><input type="date" class="form-control" v-model="costForm.date"></div>
<div class="col-12"><label class="form-label small">Megjegyzés</label><textarea class="form-control" rows="2" v-model="costForm.description"></textarea></div>
<div class="col-12"><label class="form-label fw-bold small"><i class="bi bi-paperclip me-1"></i>Bizonylat</label><input type="file" class="form-control" ref="fileInput"></div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4"><button class="btn btn-light" @click="showCostModal = false">Mégse</button><button class="btn btn-success" @click="submitCost">Mentés</button></div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom"><div class="modal-content-custom bg-danger bg-opacity-10 border border-danger"><h4 class="fw-bold text-danger">Hiba</h4><textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea><div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div><div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div></div></div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return {
view: 'dashboard', detailTab: 'overview', showErrorModal: false, showCostModal: false, myCars: [], selectedCar: null, history: [], errorForm: {description: '', is_critical: false},
costForm: { mainCategory: '', subCategory: '', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' },
costDefinitions: {}
}},
computed: {
currentSubOptions() { return (this.costDefinitions[this.costForm.mainCategory]) ? this.costDefinitions[this.costForm.mainCategory].subs : null; }
},
methods: {
async fetchRefData() {
const res = await fetch('/api/ref/cost_types');
this.costDefinitions = await res.json();
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
},
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) { const res = await fetch('/api/vehicle/' + id); this.selectedCar = await res.json(); this.view = 'detail'; this.detailTab = 'overview'; },
openCostModal() {
this.costForm = { mainCategory: Object.keys(this.costDefinitions)[0], subCategory: '', amount: '', currency: this.selectedCar.currency || 'HUF', mileage: this.selectedCar.mileage, date: new Date().toISOString().split('T')[0], description: '' };
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
if(!this.costDefinitions[this.costForm.mainCategory]) return;
const subs = this.costDefinitions[this.costForm.mainCategory].subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const finalType = this.costForm.subCategory || this.costForm.mainCategory;
const formData = new FormData();
formData.append('vehicle_id', this.selectedCar.id); formData.append('cost_type', finalType); formData.append('amount', this.costForm.amount); formData.append('currency', this.costForm.currency); formData.append('mileage', this.costForm.mileage); formData.append('date_str', this.costForm.date); formData.append('description', this.costForm.description);
const fileInput = this.$refs.fileInput; if(fileInput.files.length > 0) formData.append('file', fileInput.files[0]);
try {
const res = await fetch('/api/add_cost', { method: 'POST', body: formData });
if (res.ok) { alert("Költség rögzítve!"); this.showCostModal = false; this.openVehicleDetail(this.selectedCar.id); if(this.detailTab === 'history') this.loadHistory(); } else { alert("Hiba: " + JSON.stringify(await res.json())); }
} catch(e) { alert("Szerver hiba!"); }
},
async loadHistory() { this.detailTab = 'history'; const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history'); this.history = await res.json(); },
formatMoney(amount, currency) { try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); } catch(e) { return amount + " " + currency; } },
translateType(type) {
if(!this.costDefinitions) return type;
if (this.costDefinitions[type]) return this.costDefinitions[type].label;
for (const main in this.costDefinitions) {
if (this.costDefinitions[main].subs && this.costDefinitions[main].subs[type]) return this.costDefinitions[main].subs[type];
}
const map = { 'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció' };
return map[type] || type;
},
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() { await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) }); this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id); },
async resolveIssue() { if(!confirm("Megjavítva?")) return; await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) }); this.openVehicleDetail(this.selectedCar.id); }
},
mounted() { this.fetchData(); this.fetchRefData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; } /* PIROS CSÍK */
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'">Garázs</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'">Csapat</a></li>
</ul>
<div v-if="activeTab === 'garage'" class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">
{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}
</span>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'"><h3>Csapat...</h3></div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<button class="btn btn-outline-danger" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
</div>
<div class="row g-4">
<div class="col-md-3">
<div class="stat-box"><div class="stat-value">{{ selectedCar.mileage }}</div><div class="stat-label">Km</div></div>
</div>
<div class="col-md-6">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100">
<h6 class="fw-bold">Jelentett hiba:</h6>
<p class="mb-0">{{ selectedCar.current_issue }}</p>
</div>
<div v-else class="alert alert-success h-100 d-flex align-items-center justify-content-center">Minden rendben.</div>
</div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom">
<div class="modal-content-custom bg-danger bg-opacity-10 border border-danger">
<h4 class="fw-bold text-danger">Hiba bejelentése</h4>
<textarea class="form-control my-3" rows="3" v-model="errorForm.description" placeholder="Mi a gond?"></textarea>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" v-model="errorForm.is_critical">
<label class="form-check-label">Kritikus (Mozgásképtelen)</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showErrorModal = false">Mégse</button>
<button class="btn btn-danger" @click="submitError">Mentés</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return { view: 'dashboard', activeTab: 'garage', showErrorModal: false, myCars: [], selectedCar: null, errorForm: {description: '', is_critical: false} } },
methods: {
async fetchData() {
const res = await fetch('/api/my_vehicles');
this.myCars = await res.json();
},
async openVehicleDetail(id) {
const res = await fetch('/api/vehicle/' + id);
this.selectedCar = await res.json();
this.view = 'detail';
},
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() {
// VALÓS API HÍVÁS
try {
const res = await fetch('/api/report_issue', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vehicle_id: this.selectedCar.id,
description: this.errorForm.description,
is_critical: this.errorForm.is_critical
})
});
if (res.ok) {
alert("Hiba naplózva az adatbázisba!");
this.showErrorModal = false;
this.openVehicleDetail(this.selectedCar.id); // Adatok frissítése
}
} catch(e) { alert("Hiba a mentéskor!"); }
}
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/frontend/index.html","entries":[{"id":"Rpxl.html","timestamp":1768942812487},{"id":"uiHD.html","timestamp":1768944736458},{"id":"W3D5.html","timestamp":1768945170064},{"id":"Smd7.html","timestamp":1768945219838},{"id":"JA3R.html","timestamp":1768945489618},{"id":"eiJs.html","timestamp":1768945771369},{"id":"J4V4.html","timestamp":1768946012393},{"id":"u9Y2.html","timestamp":1768946248437},{"id":"voAw.html","timestamp":1768946585191},{"id":"Ycqg.html","timestamp":1768946927538},{"id":"9eYi.html","timestamp":1768947157813},{"id":"0xVC.html","timestamp":1768948398804},{"id":"eVfD.html","timestamp":1768948761622},{"id":"SxEq.html","timestamp":1768952718461},{"id":"83UF.html","timestamp":1768953098851},{"id":"eRba.html","source":"undoRedo.source","timestamp":1768953103288},{"id":"SVyy.html","timestamp":1768953182984},{"id":"x8lC.html","timestamp":1768953329888},{"id":"UAc0.html","timestamp":1768954114635},{"id":"UE2n.html","timestamp":1768954243537},{"id":"tEyM.html","timestamp":1768954546782},{"id":"iTl3.html","timestamp":1768954770366},{"id":"E3XE.html","timestamp":1768954920487}]}

View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<title>Service Finder - Full Control</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root { --primary: #0d6efd; --bg: #f8f9fa; }
body { background: var(--bg); font-family: 'Inter', sans-serif; }
.navbar { background: white !important; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.nav-link { color: #555 !important; font-weight: 500; }
.nav-link.active { color: var(--primary) !important; }
.garage-card { background: white; border-radius: 16px; border: none; transition: 0.3s; cursor: pointer; }
.garage-card:hover { transform: translateY(-5px); box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
.modal-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1050; display: flex; align-items: center; justify-content: center; }
.glass-panel { background: white; border-radius: 24px; padding: 35px; width: 100%; max-width: 500px; }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-expand-lg navbar-light mb-4 py-3" v-if="isLoggedIn">
<div class="container">
<a class="navbar-brand fw-bold text-primary" href="#"><i class="bi bi-tools me-2"></i>Szerviz Kereső</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" :class="{active: view==='garage'}" @click="view='garage'" href="#">Garázs</a></li>
<li class="nav-item"><a class="nav-link" href="#">Szervizek</a></li>
<li class="nav-item"><a class="nav-link" href="#">Csapatom</a></li>
</ul>
<div class="d-flex align-items-center">
<select class="form-select form-select-sm me-3" style="width: auto;">
<option>🇭🇺 HU</option>
<option>🇬🇧 EN</option>
</select>
<button class="btn btn-outline-danger btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</div>
</nav>
<div v-if="!isLoggedIn" class="d-flex align-items-center justify-content-center" style="height:100vh">
<div class="glass-panel shadow text-center">
<h2 class="fw-bold mb-4">Service Finder</h2>
<input type="email" class="form-control mb-3" v-model="auth.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="auth.password" placeholder="Jelszó">
<button class="btn btn-primary w-100 py-2" @click="login">Belépés</button>
</div>
</div>
<div v-else class="container">
<div v-if="view === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="fw-bold">Járműveim</h3>
<button class="btn btn-primary" @click="modals.reg = true"><i class="bi bi-plus-lg me-2"></i>Új Jármű</button>
</div>
<div class="row g-4">
<div class="col-md-4" v-for="car in myCars">
<div class="card garage-card shadow-sm p-4" @click="selectCar(car)">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="fw-bold mb-0">{{car.brand}}</h5>
<div class="text-muted small">{{car.model}}</div>
</div>
<span class="badge bg-primary px-3 py-2">{{car.plate}}</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="small text-muted"><i class="bi bi-cash me-1"></i> {{formatMoney(car.total_cost)}}</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-success" @click.stop="openCost(car)"><i class="bi bi-plus"></i></button>
<button class="btn btn-sm btn-outline-danger" @click.stop="deleteCar(car)"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link ps-0 mb-3 text-decoration-none" @click="view='garage'"><i class="bi bi-arrow-left"></i> Vissza a garázsba</button>
<div class="card border-0 shadow-sm p-4 rounded-4">
<div class="d-flex justify-content-between align-items-center">
<h2 class="fw-bold">{{selectedCar.brand}} {{selectedCar.model}}</h2>
<button class="btn btn-outline-danger" @click="modals.sell = true">Jármű Eladása</button>
</div>
<hr>
<div class="row text-center mt-3">
<div class="col-md-4"><h6>Állapot</h6><h4 class="text-success fw-bold">Rendben</h4></div>
<div class="col-md-4"><h6>Hibaüzenetek</h6><h4 class="text-muted">Nincs</h4></div>
<div class="col-md-4"><h6>Következő szerviz</h6><h4 class="text-primary">15,000 km múlva</h4></div>
</div>
</div>
</div>
</div>
<div v-if="modals.reg" class="modal-custom">
<div class="glass-panel">
<h4 class="fw-bold mb-4">Új Jármű Felvétele</h4>
<select class="form-select mb-3" v-model="forms.reg.cat" @change="forms.reg.brand=''; forms.reg.model_id=''">
<option value="" disabled>Kategória választása...</option>
<option v-for="(brands, cat) in meta.hierarchy" :value="cat">{{cat}}</option>
</select>
<select class="form-select mb-3" v-model="forms.reg.brand" :disabled="!forms.reg.cat">
<option value="" disabled>Márka...</option>
<option v-for="(models, brand) in meta.hierarchy[forms.reg.cat]" :value="brand">{{brand}}</option>
</select>
<select class="form-select mb-3" v-model="forms.reg.model_id" :disabled="!forms.reg.brand">
<option value="" disabled>Típus...</option>
<option v-for="m in meta.hierarchy[forms.reg.cat]?.[forms.reg.brand]" :value="m.id">{{m.name}}</option>
</select>
<input type="text" class="form-control mb-3" v-model="forms.reg.plate" placeholder="Rendszám">
<input type="number" class="form-control mb-4" v-model="forms.reg.mileage" placeholder="Aktuális km állás">
<div class="d-flex gap-2">
<button class="btn btn-light w-100" @click="modals.reg = false">Mégse</button>
<button class="btn btn-primary w-100" @click="submitReg">Mentés</button>
</div>
</div>
</div>
<div v-if="modals.cost" class="modal-custom">
<div class="glass-panel">
<h4 class="fw-bold mb-4">Költség rögzítése: {{selectedCar?.plate}}</h4>
<select class="form-select mb-3" v-model="forms.cost.type">
<option v-for="(name, code) in meta.costTypes" :value="code">{{name}}</option>
</select>
<input type="number" class="form-control mb-4" v-model="forms.cost.amount" placeholder="Összeg (HUF)">
<div class="d-flex gap-2">
<button class="btn btn-light w-100" @click="modals.cost = false">Mégse</button>
<button class="btn btn-success w-100" @click="submitCost">Rögzítés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
view: 'garage',
selectedCar: null,
myCars: [],
meta: { hierarchy: {}, costTypes: {} },
modals: { reg: false, cost: false },
auth: { email: '', password: '' },
forms: {
reg: { cat: '', brand: '', model_id: '', plate: '', mileage: '' },
cost: { type: '', amount: '' }
}
}
},
methods: {
async login() {
const p = new URLSearchParams(); p.append('username', this.auth.email); p.append('password', this.auth.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: p });
if (res.ok) {
const d = await res.json();
localStorage.setItem('token', d.access_token);
this.isLoggedIn = true;
location.reload();
}
},
async init() {
if(!this.isLoggedIn) return;
const headers = { 'Authorization': 'Bearer ' + localStorage.getItem('token') };
const [r1, r2, r3] = await Promise.all([
fetch('/api/my_vehicles', { headers }),
fetch('/api/meta/vehicle-hierarchy'),
fetch('/api/meta/cost-types')
]);
this.myCars = await r1.json();
this.meta.hierarchy = await r2.json();
this.meta.costTypes = await r3.json();
},
selectCar(car) { this.selectedCar = car; this.view = 'detail'; },
openCost(car) { this.selectedCar = car; this.modals.cost = true; },
async submitReg() {
const res = await fetch('/api/register', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('token') },
body: JSON.stringify({ ...this.forms.reg, vin: 'TEMP123' })
});
if(res.ok) { this.modals.reg = false; this.init(); }
},
async submitCost() {
const res = await fetch('/api/add_cost', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('token') },
body: JSON.stringify({ vehicle_id: this.selectedCar.vehicle_id, ...this.forms.cost })
});
if(res.ok) { this.modals.cost = false; this.init(); }
},
async deleteCar(car) {
if(confirm('Biztosan törlöd a garázsból?')) {
await fetch('/api/vehicle/' + car.vehicle_id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } });
this.init();
}
},
logout() { localStorage.clear(); this.isLoggedIn = false; },
formatMoney(v) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF' }).format(v || 0); }
},
mounted() { this.init(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<title>Service Finder - Dinamikus</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background: #f4f7f6; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.glass-card { background: white; border-radius: 15px; padding: 30px; width: 100%; max-width: 550px; box-shadow: 0 15px 35px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div id="app" class="p-3">
<div v-if="!isLoggedIn" class="d-flex align-items-center justify-content-center" style="height:90vh">
<div class="glass-card text-center">
<h2 class="fw-bold text-primary mb-4">Service Finder</h2>
<input type="email" class="form-control mb-3" v-model="auth.email" placeholder="Email">
<input type="password" class="form-control mb-3" v-model="auth.password" placeholder="Jelszó">
<button class="btn btn-primary w-100" @click="login">Belépés</button>
</div>
</div>
<div v-else class="container">
<div class="d-flex justify-content-between mb-4 mt-3">
<h3 class="fw-bold">Garázsom</h3>
<button class="btn btn-success" @click="openRegister"><i class="bi bi-plus"></i> Új Jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars">
<div class="card p-3 border-0 shadow-sm" style="border-radius:12px; border-left: 5px solid #0d6efd !important;">
<h5 class="fw-bold mb-0">{{car.brand}}</h5>
<div class="text-muted small mb-2">{{car.model}}</div>
<span class="badge bg-warning text-dark" style="width: fit-content;">{{car.plate}}</span>
</div>
</div>
</div>
</div>
<div v-if="showReg" class="modal-backdrop-custom">
<div class="glass-card">
<h4 class="fw-bold mb-4">Jármű rögzítése</h4>
<label class="small fw-bold">Kategória</label>
<select class="form-select mb-3" v-model="form.cat" @change="form.brand=''; form.model_id=''">
<option v-for="(brands, cat) in hierarchy" :value="cat">{{ cat }}</option>
</select>
<label class="small fw-bold">Márka</label>
<select class="form-select mb-3" v-model="form.brand" :disabled="!form.cat" @change="form.model_id=''">
<option v-for="(models, brand) in hierarchy[form.cat]" :value="brand">{{ brand }}</option>
</select>
<label class="small fw-bold">Modell</label>
<select class="form-select mb-3" v-model="form.model_id" :disabled="!form.brand">
<option v-for="m in hierarchy[form.cat]?.[form.brand]" :value="m.id">{{ m.name }}</option>
</select>
<div class="row g-2 mb-3">
<div class="col-6"><input type="text" class="form-control" v-model="form.plate" placeholder="Rendszám"></div>
<div class="col-6"><input type="text" class="form-control" v-model="form.vin" placeholder="Alvázszám"></div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-light" @click="showReg=false">Mégse</button>
<button class="btn btn-primary" @click="submit" :disabled="!form.model_id">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
auth: { email: '', password: '' },
myCars: [],
hierarchy: {}, // Ezt a Backendtől kapjuk
showReg: false,
form: { cat: '', brand: '', model_id: '', plate: '', vin: '', mileage: 0, purchase_date: new Date().toISOString().split('T')[0] }
}
},
methods: {
async login() {
const p = new URLSearchParams(); p.append('username', this.auth.email); p.append('password', this.auth.password);
const res = await fetch('/api/auth/login', { method: 'POST', body: p });
if (res.ok) { const d = await res.json(); localStorage.setItem('token', d.access_token); this.isLoggedIn = true; this.loadMeta(); this.loadCars(); }
},
async loadMeta() {
const res = await fetch('/api/meta/vehicle-hierarchy');
this.hierarchy = await res.json();
},
async loadCars() {
const res = await fetch('/api/my_vehicles', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } });
this.myCars = await res.json();
},
openRegister() { this.showReg = true; },
async submit() {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('token') },
body: JSON.stringify({
model_id: this.form.model_id,
vin: this.form.vin,
plate: this.form.plate,
mileage: parseInt(this.form.mileage),
purchase_date: this.form.purchase_date
})
});
if(res.ok) { this.showReg = false; this.loadCars(); }
}
},
mounted() { if(this.isLoggedIn) { this.loadMeta(); this.loadCars(); } }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
/* Timeline / History List */
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #0d6efd; }
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; }
.history-icon.FUEL { border-color: #198754; background: #198754; }
.history-icon.ISSUE_REPORT { border-color: #dc3545; background: #dc3545; }
.history-icon.ISSUE_RESOLVED { border-color: #0d6efd; background: #0d6efd; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li>
</ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">
{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség / Szerviz</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-4">
<div class="col-md-3"><div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div></div>
<div class="col-md-3">
<div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'">
<div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div><div class="stat-label">Állapot</div>
</div>
</div>
<div class="col-md-6">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger">
<div class="d-flex justify-content-between">
<div><h6 class="fw-bold text-danger">Hiba:</h6><p class="mb-2">{{ selectedCar.current_issue }}</p></div>
<button class="btn btn-sm btn-danger" @click="resolveIssue">Megjavítva</button>
</div>
</div>
<div v-else class="alert alert-light border h-100 d-flex align-items-center justify-content-center text-muted">Nincs nyitott hiba.</div>
</div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">
<i class="bi bi-journal-album display-4 mb-3 d-block opacity-25"></i>
Még üres a szervizkönyv. Rögzíts tankolást vagy eseményt!
</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">
{{ translateType(item.type) }}
<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span>
</h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<small class="text-muted" v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</small>
</div>
<div class="text-end">
<span class="badge bg-light text-dark border">{{ item.date }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-bold">Típus</label>
<select class="form-select" v-model="costForm.type">
<option value="FUEL">⛽ Tankolás</option>
<option value="SERVICE">🔧 Szerviz / Javítás</option>
<option value="INSURANCE">📄 Biztosítás</option>
<option value="TAX">🏛️ Adó / Illeték</option>
<option value="OTHER">Egyéb</option>
</select>
</div>
<div class="col-7">
<label class="form-label fw-bold">Összeg</label>
<input type="number" class="form-control" v-model="costForm.amount">
</div>
<div class="col-5">
<label class="form-label fw-bold">Pénznem</label>
<select class="form-select" v-model="costForm.currency">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-bold">Km óraállás</label>
<input type="number" class="form-control" v-model="costForm.mileage">
</div>
<div class="col-6">
<label class="form-label fw-bold">Dátum</label>
<input type="date" class="form-control" v-model="costForm.date">
</div>
<div class="col-12">
<label class="form-label">Leírás / Megjegyzés</label>
<textarea class="form-control" rows="2" v-model="costForm.description"></textarea>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-light" @click="showCostModal = false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom">
<div class="modal-content-custom bg-danger bg-opacity-10 border border-danger">
<h4 class="fw-bold text-danger">Hiba bejelentése</h4>
<textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea>
<div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div>
<div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return {
view: 'dashboard', detailTab: 'overview',
showErrorModal: false, showCostModal: false,
myCars: [], selectedCar: null, history: [],
errorForm: {description: '', is_critical: false},
costForm: { type: 'FUEL', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' }
}},
methods: {
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) {
const res = await fetch('/api/vehicle/' + id);
this.selectedCar = await res.json();
this.view = 'detail';
this.detailTab = 'overview';
},
// --- KÖLTSÉG KEZELÉS ---
openCostModal() {
// Alapértékek beállítása
this.costForm = {
type: 'FUEL', amount: '', currency: this.selectedCar.currency || 'HUF',
mileage: this.selectedCar.mileage,
date: new Date().toISOString().split('T')[0],
description: ''
};
this.showCostModal = true;
},
async submitCost() {
try {
const res = await fetch('/api/add_cost', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vehicle_id: this.selectedCar.id,
cost_type: this.costForm.type,
amount: parseFloat(this.costForm.amount),
currency: this.costForm.currency,
mileage: parseInt(this.costForm.mileage),
date: this.costForm.date,
description: this.costForm.description
})
});
if (res.ok) {
alert("Költség rögzítve!");
this.showCostModal = false;
this.openVehicleDetail(this.selectedCar.id); // Frissítjük a fő adatokat (pl. km)
if(this.detailTab === 'history') this.loadHistory(); // Frissítjük a listát ha ott vagyunk
}
} catch(e) { alert("Hiba!"); }
},
async loadHistory() {
this.detailTab = 'history';
const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history');
this.history = await res.json();
},
// --- SEGÉDEK ---
formatMoney(amount, currency) {
try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); }
catch(e) { return amount + " " + currency; }
},
translateType(type) {
const map = {
'FUEL': 'Tankolás', 'SERVICE': 'Szerviz', 'INSURANCE': 'Biztosítás', 'TAX': 'Adó',
'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció'
};
return map[type] || type;
},
// --- HIBA KEZELÉS (Meglévő) ---
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() {
await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) });
this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id);
},
async resolveIssue() {
if(!confirm("Megjavítva?")) return;
await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) });
this.openVehicleDetail(this.selectedCar.id);
}
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
/* Navigációs Fülek (Tabs) */
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
/* Kártyák */
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.car-icon { font-size: 2rem; color: #6c757d; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
/* Csapat táblázat */
.team-table th { font-weight: 600; text-transform: uppercase; font-size: 0.85rem; color: #adb5bd; }
.avatar-circle { width: 40px; height: 40px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #495057; }
/* Modal háttér */
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container">
<span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="text-white small">
<i class="bi bi-building me-1"></i> Demo Company Kft.
</div>
</div>
</nav>
<div class="container main-container">
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link" :class="{active: activeTab === 'garage'}" href="#" @click="activeTab = 'garage'">
<i class="bi bi-car-front-fill me-2"></i>Garázs
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{active: activeTab === 'team'}" href="#" @click="activeTab = 'team'">
<i class="bi bi-people-fill me-2"></i>Csapat
</a>
</li>
</ul>
<div v-if="activeTab === 'garage'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Járművek</h4>
<button class="btn btn-outline-primary btn-sm" @click="switchToWizard"><i class="bi bi-plus-lg"></i> Új rögzítése</button>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3">
<div class="card-top-strip"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0 text-dark">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i class="bi bi-car-front car-icon"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge bg-light text-dark border">{{ translateRole(car.role) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'team'">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold text-secondary mb-0">Munkatársak</h4>
<button class="btn btn-primary btn-sm" @click="showInviteModal = true">
<i class="bi bi-person-plus-fill me-2"></i>Új meghívása
</button>
</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 team-table">
<thead class="bg-light">
<tr>
<th class="ps-4">Név / Email</th>
<th>Szerepkör</th>
<th>Ország</th>
<th>Csatlakozott</th>
<th class="text-end pe-4">Műveletek</th>
</tr>
</thead>
<tbody>
<tr v-for="member in team" :key="member.email">
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="avatar-circle me-3">{{ member.email.charAt(0).toUpperCase() }}</div>
<div>
<div class="fw-bold text-dark">Munkatárs</div>
<div class="text-muted small">{{ member.email }}</div>
</div>
</div>
</td>
<td><span class="badge bg-info text-dark">{{ translateRole(member.role) }}</span></td>
<td>{{ member.country }}</td>
<td>{{ member.joined_at ? member.joined_at.split('T')[0] : 'Ma' }}</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-light text-danger"><i class="bi bi-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="team.length === 0" class="text-center p-5 text-muted">
<i class="bi bi-people display-4 mb-3 d-block opacity-25"></i>
Még nincs senki a csapatban. Hívj meg valakit!
</div>
</div>
</div>
<div v-if="showInviteModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Új munkatárs meghívása</h4>
<div class="mb-3">
<label class="form-label fw-bold">Email cím</label>
<input type="email" class="form-control" v-model="inviteForm.email" placeholder="pelda@email.hu">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Szerepkör</label>
<select class="form-select" v-model="inviteForm.role">
<option value="DRIVER">Sofőr</option>
<option value="FLEET_MANAGER">Flotta Menedzser</option>
</select>
</div>
<div class="mb-4" v-if="inviteForm.role === 'DRIVER'">
<label class="form-label fw-bold">Jogosultság Szint</label>
<div class="form-check">
<input class="form-check-input" type="radio" value="FULL" v-model="inviteForm.access_level">
<label class="form-check-label">Teljes (Költségeket is lát)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" value="LOG_ONLY" v-model="inviteForm.access_level">
<label class="form-check-label">Korlátozott (Csak rögzíthet)</label>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-light" @click="showInviteModal = false">Mégse</button>
<button class="btn btn-primary" :disabled="!inviteForm.email" @click="sendInvite">
<i class="bi bi-send-fill me-2"></i>Meghívó küldése
</button>
</div>
</div>
</div>
<div v-if="activeTab === 'wizard'" class="wizard-card main-container">
<h3>Varázsló helye...</h3>
<button @click="activeTab = 'garage'">Bezár</button>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
activeTab: 'garage',
showInviteModal: false,
myCars: [],
team: [],
inviteForm: { email: '', role: 'DRIVER', access_level: 'LOG_ONLY' }
}
},
methods: {
translateRole(role) {
const map = { 'OWNER': 'Tulajdonos', 'DRIVER': 'Sofőr', 'FLEET_MANAGER': 'Flotta Menedzser' };
return map[role] || role;
},
async fetchData() {
// 1. Garázs
const res1 = await fetch('/api/my_vehicles');
this.myCars = await res1.json();
// 2. Csapat
const res2 = await fetch('/api/fleet/members');
this.team = await res2.json();
},
async sendInvite() {
try {
const res = await fetch('/api/fleet/invite', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.inviteForm)
});
const data = await res.json();
if(res.ok) {
alert("Siker! " + data.message);
this.showInviteModal = false;
this.inviteForm.email = ''; // Reset
} else {
alert("Hiba: " + data.detail);
}
} catch(e) { alert("Hiba történt!"); }
},
switchToWizard() {
// Itt majd vissza kell hozni a wizard logikát, most csak placeholder
alert("Itt nyílna meg a varázsló, ahogy eddig.");
}
},
mounted() {
this.fetchData();
}
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.main-container { max-width: 1000px; margin: 30px auto; }
.nav-pills .nav-link { border-radius: 50px; padding: 10px 25px; font-weight: 600; color: #6c757d; margin-right: 10px; }
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); transition: transform 0.2s; cursor: pointer; height: 100%; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; font-size: 0.9rem; border-radius: 4px; }
.detail-header { background: white; border-radius: 15px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.stat-box { background: #f8f9fa; border-radius: 10px; padding: 15px; text-align: center; height: 100%; transition: 0.3s; display: flex; flex-direction: column; justify-content: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #212529; }
.stat-label { font-size: 0.85rem; color: #6c757d; text-transform: uppercase; margin-top: 5px; }
.tab-content-area { background: white; border-radius: 15px; padding: 30px; box-shadow: 0 5px 15px rgba(0,0,0,0.03); }
/* Timeline */
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-item:last-child { border-left: none; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; background: white; border-radius: 50%; border: 2px solid #0d6efd; }
.history-icon.SERVICE { border-color: #fd7e14; background: #fd7e14; }
.history-icon.FUEL { border-color: #198754; background: #198754; }
.history-icon.ISSUE_REPORT { border-color: #dc3545; background: #dc3545; }
.history-icon.ISSUE_RESOLVED { border-color: #0d6efd; background: #0d6efd; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
.modal-content-custom { background: white; padding: 30px; border-radius: 15px; width: 100%; max-width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
</style>
</head>
<body>
<div id="app">
<nav class="navbar navbar-dark bg-primary mb-4 shadow-sm py-2">
<div class="container"><span class="navbar-brand mb-0 h1 fs-5"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span></div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<ul class="nav nav-pills mb-4">
<li class="nav-item"><a class="nav-link active" href="#">Garázs</a></li>
</ul>
<div class="row g-3">
<div class="col-md-6 col-lg-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{ 'danger': car.status !== 'OK' && car.status !== null }"></div>
<div class="d-flex justify-content-between align-items-start mb-2">
<div><h5 class="fw-bold mb-0">{{ car.brand }}</h5><div class="text-muted small">{{ car.model }}</div></div>
<i v-if="car.status !== 'OK' && car.status !== null" class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<i v-else class="bi bi-car-front fs-2 text-secondary"></i>
</div>
<div class="mt-2 d-flex justify-content-between align-items-end">
<div class="plate-badge">{{ car.plate }}</div>
<span class="badge" :class="(car.status !== 'OK' && car.status !== null) ? 'bg-danger' : 'bg-light text-dark border'">
{{ (car.status !== 'OK' && car.status !== null) ? 'HIBA' : car.role }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-outline-secondary mb-3 rounded-pill" @click="view = 'dashboard'; fetchData()"><i class="bi bi-arrow-left me-2"></i>Garázs</button>
<div class="detail-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex align-items-center">
<div class="text-white rounded-circle d-flex align-items-center justify-content-center me-3"
:class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bg-danger' : 'bg-primary'" style="width: 60px; height: 60px;">
<i class="bi" :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi-exclamation-triangle' : 'bi-car-front'" style="font-size: 1.8rem;"></i>
</div>
<div>
<h2 class="fw-bold mb-0">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<div class="d-flex align-items-center mt-1 gap-2">
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
<span v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="badge bg-danger">ÁLLAPOT: {{ selectedCar.status }}</span>
</div>
</div>
</div>
<div class="text-end">
<button class="btn btn-outline-danger me-2" @click="openErrorModal"><i class="bi bi-exclamation-circle-fill me-1"></i> Jelentés</button>
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg me-1"></i> Költség</button>
</div>
</div>
<ul class="nav nav-tabs detail-tabs mb-3">
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='overview'}" href="#" @click="detailTab='overview'">Áttekintés</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: detailTab==='history'}" href="#" @click="loadHistory()">Szervizkönyv</a></li>
</ul>
<div class="tab-content-area">
<div v-if="detailTab === 'overview'">
<div class="row g-3">
<div class="col-md-3">
<div class="stat-box"><div class="stat-value">{{ selectedCar.mileage.toLocaleString() }}</div><div class="stat-label">Km</div></div>
</div>
<div class="col-md-3">
<div class="stat-box" :style="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'background: #f8d7da; color: #721c24;' : 'background: #d1e7dd; color: #0f5132;'">
<div class="stat-value"><i :class="(selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'bi bi-x-circle' : 'bi bi-check-circle'"></i> {{ (selectedCar.status !== 'OK' && selectedCar.status !== null) ? 'HIBA' : 'OK' }}</div>
<div class="stat-label">Műszaki állapot</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-box">
<div class="stat-value">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div>
<div class="stat-label">Idei költés</div>
</div>
</div>
<div class="col-md-3">
<div v-if="selectedCar.status !== 'OK' && selectedCar.status !== null" class="alert alert-danger h-100 shadow-sm border-danger p-2 m-0 d-flex flex-column justify-content-center">
<h6 class="fw-bold text-danger mb-1" style="font-size: 0.9rem">Hiba:</h6>
<p class="mb-2 text-dark small" style="line-height: 1.2">{{ selectedCar.current_issue }}</p>
<button class="btn btn-sm btn-danger w-100" @click="resolveIssue">Megjavítva</button>
</div>
<div v-else class="stat-box text-muted border bg-light">
<div class="fs-4"><i class="bi bi-shield-check"></i></div>
<div class="stat-label">Nincs hiba</div>
</div>
</div>
</div>
</div>
<div v-if="detailTab === 'history'">
<div v-if="history.length === 0" class="text-center py-5 text-muted">Még üres a szervizkönyv.</div>
<div v-else class="ps-3 pt-2">
<div v-for="item in history" :key="item.date + item.type" class="history-item">
<div class="history-icon" :class="item.type"></div>
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">
{{ translateType(item.type) }}
<span v-if="item.amount" class="text-success ms-2">+{{ formatMoney(item.amount, item.currency) }}</span>
</h6>
<p class="text-muted small mb-0">{{ item.description }}</p>
<small class="text-muted" v-if="item.mileage > 0"><i class="bi bi-speedometer2 me-1"></i>{{ item.mileage.toLocaleString() }} km</small>
</div>
<div class="text-end"><span class="badge bg-light text-dark border">{{ item.date }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="modal-content-custom">
<h4 class="fw-bold mb-4">Költség / Esemény</h4>
<div class="row g-3">
<div class="col-12"><label class="form-label fw-bold">Típus</label><select class="form-select" v-model="costForm.type"><option value="FUEL">⛽ Tankolás</option><option value="SERVICE">🔧 Szerviz</option><option value="INSURANCE">📄 Biztosítás</option><option value="TAX">🏛️ Adó</option><option value="OTHER">Egyéb</option></select></div>
<div class="col-7"><label class="form-label fw-bold">Összeg</label><input type="number" class="form-control" v-model="costForm.amount"></div>
<div class="col-5"><label class="form-label fw-bold">Pénznem</label><select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select></div>
<div class="col-6"><label class="form-label fw-bold">Km</label><input type="number" class="form-control" v-model="costForm.mileage"></div>
<div class="col-6"><label class="form-label fw-bold">Dátum</label><input type="date" class="form-control" v-model="costForm.date"></div>
<div class="col-12"><label class="form-label">Megjegyzés</label><textarea class="form-control" rows="2" v-model="costForm.description"></textarea></div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4"><button class="btn btn-light" @click="showCostModal = false">Mégse</button><button class="btn btn-success" @click="submitCost">Mentés</button></div>
</div>
</div>
<div v-if="showErrorModal" class="modal-backdrop-custom">
<div class="modal-content-custom bg-danger bg-opacity-10 border border-danger">
<h4 class="fw-bold text-danger">Hiba</h4>
<textarea class="form-control my-3" rows="3" v-model="errorForm.description"></textarea>
<div class="form-check mb-3"><input class="form-check-input" type="checkbox" v-model="errorForm.is_critical"><label class="form-check-label">Kritikus</label></div>
<div class="d-flex justify-content-end gap-2"><button class="btn btn-light" @click="showErrorModal = false">Mégse</button><button class="btn btn-danger" @click="submitError">Mentés</button></div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() { return { view: 'dashboard', detailTab: 'overview', showErrorModal: false, showCostModal: false, myCars: [], selectedCar: null, history: [], errorForm: {description: '', is_critical: false}, costForm: { type: 'FUEL', amount: 0, currency: 'HUF', mileage: 0, date: '', description: '' } }},
methods: {
async fetchData() { const res = await fetch('/api/my_vehicles'); this.myCars = await res.json(); },
async openVehicleDetail(id) { const res = await fetch('/api/vehicle/' + id); this.selectedCar = await res.json(); this.view = 'detail'; this.detailTab = 'overview'; },
openCostModal() { this.costForm = { type: 'FUEL', amount: '', currency: this.selectedCar.currency || 'HUF', mileage: this.selectedCar.mileage, date: new Date().toISOString().split('T')[0], description: '' }; this.showCostModal = true; },
async submitCost() {
const res = await fetch('/api/add_cost', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, cost_type: this.costForm.type, amount: parseFloat(this.costForm.amount), currency: this.costForm.currency, mileage: parseInt(this.costForm.mileage), date: this.costForm.date, description: this.costForm.description }) });
if (res.ok) { this.showCostModal = false; this.openVehicleDetail(this.selectedCar.id); if(this.detailTab === 'history') this.loadHistory(); }
},
async loadHistory() { this.detailTab = 'history'; const res = await fetch('/api/vehicle/' + this.selectedCar.id + '/history'); this.history = await res.json(); },
formatMoney(amount, currency) { try { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: currency }).format(amount); } catch(e) { return amount + " " + currency; } },
translateType(type) { const map = { 'FUEL': 'Tankolás', 'SERVICE': 'Szerviz', 'INSURANCE': 'Biztosítás', 'TAX': 'Adó', 'ISSUE_REPORT': 'Hiba Bejelentés', 'ISSUE_RESOLVED': 'Hiba Javítva', 'MILEAGE_UPDATE': 'Km Korrekció' }; return map[type] || type; },
openErrorModal() { this.errorForm = {description: '', is_critical: false}; this.showErrorModal = true; },
async submitError() { await fetch('/api/report_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id, description: this.errorForm.description, is_critical: this.errorForm.is_critical }) }); this.showErrorModal = false; this.openVehicleDetail(this.selectedCar.id); },
async resolveIssue() { if(!confirm("Megjavítva?")) return; await fetch('/api/resolve_issue', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ vehicle_id: this.selectedCar.id }) }); this.openVehicleDetail(this.selectedCar.id); }
},
mounted() { this.fetchData(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Finder - Flotta Kezelő</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body { background-color: #f0f2f5; font-family: 'Segoe UI', sans-serif; }
.auth-wrapper { height: 100vh; display: flex; align-items: center; justify-content: center; }
.auth-card { background: white; padding: 40px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.main-container { max-width: 1000px; margin: 30px auto; }
.garage-card { border: none; border-radius: 12px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.04); cursor: pointer; transition: 0.2s; position: relative; overflow: hidden; }
.garage-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
.card-top-strip { height: 6px; background: #0d6efd; width: 100%; position: absolute; top: 0; left: 0; }
.card-top-strip.danger { background: #dc3545; }
.plate-badge { background: #ffcc00; color: black; border: 1px solid #e0b000; font-family: 'Courier New', monospace; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.history-item { border-left: 3px solid #e9ecef; padding-left: 20px; padding-bottom: 20px; position: relative; }
.history-icon { position: absolute; left: -11px; top: 0; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; }
.modal-backdrop-custom { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1040; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="app">
<div v-if="!isLoggedIn" class="auth-wrapper">
<div class="auth-card">
<h2 class="fw-bold mb-4 text-center text-primary"><i class="bi bi-speedometer2"></i> Service Finder</h2>
<div v-if="authView === 'login'">
<div class="mb-3">
<label class="form-label small">Email</label>
<input type="email" class="form-control" v-model="authForm.email" placeholder="email@pelda.hu">
</div>
<div class="mb-3">
<label class="form-label small">Jelszó</label>
<input type="password" class="form-control" v-model="authForm.password" placeholder="******">
</div>
<button class="btn btn-primary w-100 py-2" @click="login">Bejelentkezés</button>
<p class="text-center mt-3 small">Nincs fiókod? <a href="#" @click="authView='register'">Regisztrálj!</a></p>
</div>
<div v-if="authView === 'register'">
<div class="mb-3">
<label class="form-label small">Új Email</label>
<input type="email" class="form-control" v-model="authForm.email">
</div>
<div class="mb-3">
<label class="form-label small">Új Jelszó</label>
<input type="password" class="form-control" v-model="authForm.password">
</div>
<button class="btn btn-success w-100 py-2" @click="register">Regisztráció</button>
<p class="text-center mt-3 small"><a href="#" @click="authView='login'">Vissza a belépéshez</a></p>
</div>
</div>
</div>
<div v-else>
<nav class="navbar navbar-dark bg-primary shadow-sm px-4 py-2">
<div class="container">
<span class="navbar-brand fw-bold"><i class="bi bi-speedometer2 me-2"></i>Service Finder</span>
<div class="d-flex align-items-center">
<span class="text-white me-3 small">{{ authForm.email }}</span>
<button class="btn btn-outline-light btn-sm" @click="logout"><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</nav>
<div class="container main-container">
<div v-if="view === 'dashboard'">
<div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold">Saját Járművek</h4>
<button class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Új jármű</button>
</div>
<div class="row g-3">
<div class="col-md-4" v-for="car in myCars" :key="car.vehicle_id">
<div class="card garage-card p-3" @click="openVehicleDetail(car.vehicle_id)">
<div class="card-top-strip" :class="{danger: car.status !== 'OK'}"></div>
<h5 class="fw-bold mb-0">{{ car.brand }}</h5>
<div class="text-muted small mb-2">{{ car.model }}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="plate-badge">{{ car.plate }}</span>
<i v-if="car.status !== 'OK'" class="bi bi-exclamation-triangle text-danger"></i>
</div>
</div>
</div>
</div>
<div v-if="myCars.length === 0" class="text-center mt-5 text-muted">
<i class="bi bi-car-front fs-1 d-block mb-3"></i>
A garázsod még üres.
</div>
</div>
<div v-if="view === 'detail' && selectedCar">
<button class="btn btn-link text-decoration-none ps-0 mb-3" @click="view='dashboard'; initApp()"><i class="bi bi-arrow-left"></i> Vissza a listához</button>
<div class="detail-header shadow-sm border p-4 bg-white rounded-4 mb-4">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="fw-bold mb-1">{{ selectedCar.brand }} {{ selectedCar.model }}</h2>
<span class="plate-badge fs-6">{{ selectedCar.plate }}</span>
</div>
<div class="text-end">
<button class="btn btn-success" @click="openCostModal"><i class="bi bi-plus-lg"></i> Költség</button>
</div>
</div>
</div>
<div class="row g-3 text-center">
<div class="col-md-4"><div class="stat-box border bg-white p-3 rounded-4"><h6>Km állás</h6><div class="fw-bold fs-4">{{ selectedCar.mileage }}</div></div></div>
<div class="col-md-4"><div class="stat-box border bg-white p-3 rounded-4"><h6>Idei költség</h6><div class="fw-bold fs-4">{{ formatMoney(selectedCar.year_cost, selectedCar.currency) }}</div></div></div>
<div class="col-md-4"><div class="stat-box border bg-white p-3 rounded-4"><h6>Állapot</h6><div :class="selectedCar.status === 'OK' ? 'text-success' : 'text-danger'" class="fw-bold fs-4">{{ selectedCar.status }}</div></div></div>
</div>
</div>
</div>
</div>
<div v-if="showCostModal" class="modal-backdrop-custom">
<div class="auth-card" style="max-width: 500px;">
<h4 class="fw-bold mb-4">Új Költség Rögzítése</h4>
<div class="row g-3">
<div class="col-6">
<label class="small fw-bold">Kategória</label>
<select class="form-select" v-model="costForm.mainCategory" @change="updateSubCategory">
<option v-for="(val, key) in costDefinitions" :value="key">{{ val.label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Típus</label>
<select class="form-select" v-model="costForm.subCategory">
<option v-for="(label, key) in currentSubOptions" :value="key">{{ label }}</option>
</select>
</div>
<div class="col-6">
<label class="small fw-bold">Összeg</label>
<input type="number" class="form-control" v-model="costForm.amount">
</div>
<div class="col-6">
<label class="small fw-bold">Pénznem</label>
<select class="form-select" v-model="costForm.currency"><option value="HUF">HUF</option><option value="EUR">EUR</option></select>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-light me-2" @click="showCostModal=false">Mégse</button>
<button class="btn btn-success" @click="submitCost">Mentés</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
isLoggedIn: !!localStorage.getItem('token'),
authView: 'login',
view: 'dashboard',
authForm: { email: '', password: '' },
myCars: [],
selectedCar: null,
showCostModal: false,
costForm: { mainCategory: '', subCategory: '', amount: '', currency: 'HUF' },
costDefinitions: {}
}
},
computed: {
currentSubOptions() { return this.costDefinitions[this.costForm.mainCategory]?.subs || null; }
},
methods: {
async login() {
const params = new URLSearchParams();
params.append('username', this.authForm.email);
params.append('password', this.authForm.password);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('token', data.access_token);
this.isLoggedIn = true;
this.initApp();
} else {
const err = await res.json();
alert("Hiba: " + (err.detail || "Sikertelen belépés"));
}
} catch (e) { alert("Szerver hiba!"); }
},
async register() {
const fd = new FormData();
fd.append('email', this.authForm.email);
fd.append('password', this.authForm.password);
try {
const res = await fetch('/api/auth/register', { method: 'POST', body: fd });
if(res.ok) {
alert("Regisztráció sikeres!");
this.authView = 'login';
} else {
const err = await res.json();
alert("Hiba: " + (err.detail || "Sikertelen regisztráció"));
}
} catch(e) { alert("Szerver hiba!"); }
},
logout() { localStorage.removeItem('token'); this.isLoggedIn = false; },
async apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers, 'Authorization': `Bearer \${token}` };
const res = await fetch(url, options);
if(res.status === 401) this.logout();
return res;
},
async initApp() {
if(!this.isLoggedIn) return;
try {
const resCars = await this.apiFetch('/api/my_vehicles');
this.myCars = await resCars.json();
const resTypes = await fetch('/api/ref/cost_types');
this.costDefinitions = await resTypes.json();
} catch(e) { console.error("Adatbetöltési hiba", e); }
},
async openVehicleDetail(id) {
const res = await this.apiFetch(`/api/vehicle/\${id}`);
this.selectedCar = await res.json();
this.view = 'detail';
},
openCostModal() {
this.costForm.mainCategory = Object.keys(this.costDefinitions)[0];
this.updateSubCategory();
this.showCostModal = true;
},
updateSubCategory() {
const subs = this.costDefinitions[this.costForm.mainCategory]?.subs;
this.costForm.subCategory = subs ? Object.keys(subs)[0] : '';
},
async submitCost() {
const fd = new FormData();
fd.append('vehicle_id', this.selectedCar.id);
fd.append('cost_type', this.costForm.subCategory || this.costForm.mainCategory);
fd.append('amount', this.costForm.amount);
fd.append('currency', this.costForm.currency);
fd.append('date_str', new Date().toISOString().split('T')[0]);
fd.append('mileage', this.selectedCar.mileage);
const res = await this.apiFetch('/api/add_cost', { method: 'POST', body: fd });
if(res.ok) { this.showCostModal = false; this.openVehicleDetail(this.selectedCar.id); }
},
formatMoney(amount, curr) { return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: curr || 'HUF' }).format(amount); }
},
mounted() { this.initApp(); }
}).mount('#app')
</script>
</body>
</html>

View File

@@ -0,0 +1,65 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 1. Konfiguráció importálása
from app.core.config import settings
# 2. Adatbázis importok (JAVÍTVA)
# A 'Base' tartalmazza a modellek definícióit (app/db/base.py)
from app.db.base import Base
# Az 'engine' kezeli a kapcsolatot (app/db/session.py)
from app.db.session import engine
# 3. Router importálása
from app.api.v1.router import api_router
# --- Lifespan (Életciklus) Kezelő ---
# Ez fut le az alkalmazás indulásakor és leállásakor
@asynccontextmanager
async def lifespan(app: FastAPI):
# INDÍTÁS (Startup): Táblák létrehozása fejlesztői módban
async with engine.begin() as conn:
# Fontos: Importálnunk kell a modelleket, hogy a Base tudjon róluk,
# mielőtt létrehozzuk a táblákat!
# Ha vannak új modelljeid (pl. logistics, user), írd hozzá őket ide:
from app.models import social, vehicle, user, logistics
print("--- Adatbázis táblák ellenőrzése és létrehozása ---")
await conn.run_sync(Base.metadata.create_all)
yield # Itt fut az alkalmazás...
# LEÁLLÁS (Shutdown): Erőforrások elengedése
print("--- Traffic Ecosystem SuperApp leállítása ---")
await engine.dispose()
# --- Alkalmazás Inicializálása ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
# --- Middleware (CORS) ---
# Engedélyezzük, hogy a frontend kommunikálhasson az API-val
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # FIGYELEM: Élesben ezt szigorítani kell (pl. a frontend domainjére)!
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routerek csatolása ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Gyökér végpont ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API fut",
"version": settings.VERSION,
"docs_url": "/docs" # Segítség a fejlesztőknek
}

View File

@@ -0,0 +1,65 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 1. Konfiguráció importálása
from app.core.config import settings
# 2. Adatbázis importok (JAVÍTVA)
# A 'Base' tartalmazza a modellek definícióit (app/db/base.py)
from app.db.base import Base
# Az 'engine' kezeli a kapcsolatot (app/db/session.py)
from app.db.session import engine
# 3. Router importálása
from app.api.v1.router import api_router
# --- Lifespan (Életciklus) Kezelő ---
# Ez fut le az alkalmazás indulásakor és leállásakor
@asynccontextmanager
async def lifespan(app: FastAPI):
# INDÍTÁS (Startup): Táblák létrehozása fejlesztői módban
async with engine.begin() as conn:
# Fontos: Importálnunk kell a modelleket, hogy a Base tudjon róluk,
# mielőtt létrehozzuk a táblákat!
# Ha vannak új modelljeid (pl. logistics, user), írd hozzá őket ide:
from app.models import social, vehicle, user
print("--- Adatbázis táblák ellenőrzése és létrehozása ---")
await conn.run_sync(Base.metadata.create_all)
yield # Itt fut az alkalmazás...
# LEÁLLÁS (Shutdown): Erőforrások elengedése
print("--- Traffic Ecosystem SuperApp leállítása ---")
await engine.dispose()
# --- Alkalmazás Inicializálása ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
# --- Middleware (CORS) ---
# Engedélyezzük, hogy a frontend kommunikálhasson az API-val
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # FIGYELEM: Élesben ezt szigorítani kell (pl. a frontend domainjére)!
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routerek csatolása ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Gyökér végpont ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API fut",
"version": settings.VERSION,
"docs_url": "/docs" # Segítség a fejlesztőknek
}

View File

@@ -0,0 +1,54 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import engine, Base
from app.api.v1.router import api_router
# --- Lifespan Manager (Startup & Shutdown Logic) ---
@asynccontextmanager
async def lifespan(app: FastAPI):
# STARTUP: Adatbázis táblák létrehozása
# Figyelem: Éles környezetben (Production) inkább Alembic migrációt használunk!
async with engine.begin() as conn:
# Betöltjük a modelleket, hogy a metadata tudjon róluk
# Fontos: importálni kell őket, különben nem jönnek létre!
from app.models import social, vehicle, user, logistics
await conn.run_sync(Base.metadata.create_all)
print("--- Database Tables Checked/Created ---")
yield # Itt fut az alkalmazás...
# SHUTDOWN: Erőforrások felszabadítása (ha lenne)
print("--- Shutting down ---")
# --- App Initialization ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
# --- Middleware (CORS) ---
# Engedélyezzük a frontend kommunikációt
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Élesben szigorítani kell!
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routers ---
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API is Running",
"docs_url": "/docs",
"version": settings.VERSION
}

View File

@@ -0,0 +1,68 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 1. Config Import
from app.core.config import settings
# 2. Database Imports (FIXED PATHS)
# 'Base' contains the model definitions (from app/db/base.py)
from app.db.base import Base
# 'engine' handles the connection (assumed to be in app/db/session.py)
from app.db.session import engine
# 3. Router Import
from app.api.v1.router import api_router
# --- Lifespan Manager (Startup & Shutdown Logic) ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Handles application startup and shutdown events.
"""
# STARTUP: Create Database Tables (Development Mode)
# In production, you should use Alembic migrations instead of this.
async with engine.begin() as conn:
# We must import models here so that Base.metadata knows about them
# before we call create_all.
# Ensure these files exist in app/models/
from app.models import social, vehicle # Add 'user', 'logistics' if created
print("--- Checking/Creating Database Tables ---")
await conn.run_sync(Base.metadata.create_all)
yield # The application runs here
# SHUTDOWN: Cleanup resources
print("--- Shutting down Traffic Ecosystem SuperApp ---")
await engine.dispose()
# --- App Initialization ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
# --- Middleware (CORS) ---
# Allows the frontend to communicate with this API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # WARNING: Restrict this in production!
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routers ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Root Endpoint ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API is Running",
"version": settings.VERSION,
"docs_url": "/docs"
}

View File

@@ -0,0 +1,100 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
# 1. Konfiguráció importálása
from app.core.config import settings
# 2. Adatbázis importok
from app.db.base import Base
from app.db.session import engine
# 3. Router importálása
from app.api.v1.router import api_router
# --- Lifespan (Életciklus) Kezelő ---
@asynccontextmanager
async def lifespan(app: FastAPI):
# INDÍTÁS (Startup): Táblák létrehozása fejlesztői módban
async with engine.begin() as conn:
# Importáljuk a modelleket, hogy a Base tudjon róluk!
from app.models import social, vehicle, user, logistics, expense
print("--- Adatbázis táblák ellenőrzése és létrehozása ---")
await conn.run_sync(Base.metadata.create_all)
yield # Itt fut az alkalmazás...
# LEÁLLÁS (Shutdown): Erőforrások elengedése
print("--- Traffic Ecosystem SuperApp leállítása ---")
await engine.dispose()
# --- Alkalmazás Inicializálása ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan,
# Ez a beállítás segít, hogy a lakat megmaradjon frissítés után is
swagger_ui_parameters={"persistAuthorization": True}
)
# --- OpenAPI Biztonsági Séma (Hogy legyen Authorize gomb!) ---
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description="API dokumentáció JWT hitelesítéssel",
routes=app.routes,
)
# Itt definiáljuk a lakatot és a JWT formátumot
openapi_schema["components"]["securitySchemes"] = {
"OAuth2PasswordBearer": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": "/api/v1/auth/login"
}
}
}
}
# Globálisan alkalmazzuk minden védett végpontra
for path in openapi_schema["paths"]:
for method in openapi_schema["paths"][path]:
# Csak azokra a kérésekre rakunk lakatot, amik nincsenek a kivételek között
# (Az auth/login és auth/register maradjon nyitva)
if not any(x in path for x in ["/auth/login", "/auth/register"]):
openapi_schema["paths"][path][method]["security"] = [{"OAuth2PasswordBearer": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
# Felülírjuk az alapértelmezett OpenAPI generátort a sajátunkkal
app.openapi = custom_openapi
# --- Middleware (CORS) ---
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routerek csatolása ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Gyökér végpont ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API fut",
"version": settings.VERSION,
"docs_url": "/docs"
}

View File

@@ -0,0 +1,100 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
# 1. Konfiguráció importálása
from app.core.config import settings
# 2. Adatbázis importok
from app.db.base import Base
from app.db.session import engine
# 3. Router importálása
from app.api.v1.router import api_router
# --- Lifespan (Életciklus) Kezelő ---
@asynccontextmanager
async def lifespan(app: FastAPI):
# INDÍTÁS (Startup): Táblák létrehozása fejlesztői módban
async with engine.begin() as conn:
# Importáljuk a modelleket, hogy a Base tudjon róluk!
from app.models import social, vehicle, user, logistics, expense
print("--- Adatbázis táblák ellenőrzése és létrehozása ---")
await conn.run_sync(Base.metadata.create_all)
yield # Itt fut az alkalmazás...
# LEÁLLÁS (Shutdown): Erőforrások elengedése
print("--- Traffic Ecosystem SuperApp leállítása ---")
await engine.dispose()
# --- Alkalmazás Inicializálása ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan,
# Ez a beállítás segít, hogy a lakat megmaradjon frissítés után is
swagger_ui_parameters={"persistAuthorization": True}
)
# --- OpenAPI Biztonsági Séma (Hogy legyen Authorize gomb!) ---
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description="API dokumentáció JWT hitelesítéssel",
routes=app.routes,
)
# Itt definiáljuk a lakatot és a JWT formátumot
openapi_schema["components"]["securitySchemes"] = {
"OAuth2PasswordBearer": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": f"{settings.API_V1_STR}/auth/login"
}
}
}
}
# Globálisan alkalmazzuk minden védett végpontra
for path in openapi_schema["paths"]:
for method in openapi_schema["paths"][path]:
# Csak azokra a kérésekre rakunk lakatot, amik nincsenek a kivételek között
# (Az auth/login és auth/register maradjon nyitva)
if not any(x in path for x in ["/auth/login", "/auth/register"]):
openapi_schema["paths"][path][method]["security"] = [{"OAuth2PasswordBearer": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
# Felülírjuk az alapértelmezett OpenAPI generátort a sajátunkkal
app.openapi = custom_openapi
# --- Middleware (CORS) ---
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routerek csatolása ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Gyökér végpont ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API fut",
"version": settings.VERSION,
"docs_url": "/docs"
}

View File

@@ -0,0 +1 @@
{"version":1,"resource":"vscode-remote://192.168.100.43:8443/home/coder/project/backend/app/main.py","entries":[{"id":"FkfR.py","timestamp":1769026236612},{"id":"Fp0w.py","timestamp":1769027988167},{"id":"jmij.py","timestamp":1769028127118},{"id":"9MWC.py","timestamp":1769031896319},{"id":"54N2.py","timestamp":1769032666605},{"id":"eSN2.py","timestamp":1769043761338},{"id":"MlfW.py","timestamp":1769106292762}]}

View File

@@ -0,0 +1,65 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 1. Konfiguráció importálása
from app.core.config import settings
# 2. Adatbázis importok (JAVÍTVA)
# A 'Base' tartalmazza a modellek definícióit (app/db/base.py)
from app.db.base import Base
# Az 'engine' kezeli a kapcsolatot (app/db/session.py)
from app.db.session import engine
# 3. Router importálása
from app.api.v1.router import api_router
# --- Lifespan (Életciklus) Kezelő ---
# Ez fut le az alkalmazás indulásakor és leállásakor
@asynccontextmanager
async def lifespan(app: FastAPI):
# INDÍTÁS (Startup): Táblák létrehozása fejlesztői módban
async with engine.begin() as conn:
# Fontos: Importálnunk kell a modelleket, hogy a Base tudjon róluk,
# mielőtt létrehozzuk a táblákat!
# Ha vannak új modelljeid (pl. logistics, user), írd hozzá őket ide:
from app.models import social, vehicle
print("--- Adatbázis táblák ellenőrzése és létrehozása ---")
await conn.run_sync(Base.metadata.create_all)
yield # Itt fut az alkalmazás...
# LEÁLLÁS (Shutdown): Erőforrások elengedése
print("--- Traffic Ecosystem SuperApp leállítása ---")
await engine.dispose()
# --- Alkalmazás Inicializálása ---
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
# --- Middleware (CORS) ---
# Engedélyezzük, hogy a frontend kommunikálhasson az API-val
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # FIGYELEM: Élesben ezt szigorítani kell (pl. a frontend domainjére)!
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routerek csatolása ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- Gyökér végpont ---
@app.get("/")
async def root():
return {
"message": "Traffic Ecosystem SuperApp API fut",
"version": settings.VERSION,
"docs_url": "/docs" # Segítség a fejlesztőknek
}

View File

@@ -0,0 +1,74 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_port=5432
# --- ADATBÁZIS ELÉRÉS (A Python kódnak) ---
# Formátum: postgresql+asyncpg://FELHASZNÁLÓ:JELSZÓ@KONTÉNER_NÉV:PORT/ADATBÁZIS_NÉV
# DATABASE_URL=postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PM senior
Egyéni gem
Ez egy kiváló észrevétel. Nagyon jól teszed, hogy ezeket a kritikus kulcsokat (SECRET_KEY, ALGORITHM) is beemeled a biztonságos környezetbe. Ezek felelnek ugyanis azért, hogy a felhasználóid bejelentkezve maradjanak, és a jelszavak biztonságban legyenek.
Összeraktam neked a TELJES, VÉGLEGES .env fájlt, ami egyesíti a te meglévő beállításaidat az általam javasolt újakkal.
1. A Master .env fájl (Ezt hozd létre)
Ebben a fájlban definiáljuk az "igazságot". A Docker Compose innen fogja kiolvasni az értékeket.
Másold be ezt a fájlt a projekt gyökérkönyvtárába (.env néven):
Properties
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=PASSWORD111
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=szuper_titkos_random_string_amit_senki_nem_tud
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,39 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=PASSWORD111
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}
# DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,45 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
# DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,44 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=PASSWORD111
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,60 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=AppSafePass_2026
# DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}
# DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11
SECRET_KEY=SzuperTitkosVéletlenszerűKaraktersorozat_Amit_Csak_Te_Tudsz_2026
SECRET_KEY="2e34bfff3e448c6d6f75cbacf65035e3a833e6648053e8e97ce37c6fa82b6525"
SENDGRID_API_KEY=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
FROM_EMAIL=info@profibot.hu

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
# DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${APP_DB_HOST}:${APP_DB_PORT}/${POSTGRES_DB}
DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,32 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_port=5432
# --- ADATBÁZIS ELÉRÉS (A Python kódnak) ---
# Formátum: postgresql+asyncpg://FELHASZNÁLÓ:JELSZÓ@KONTÉNER_NÉV:PORT/ADATBÁZIS_NÉV
# DATABASE_URL=postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# --- REDIS (A session kezeléshez) ---
REDIS_URL=redis://redis:6379/0
# PgAdmin
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,44 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,60 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=AppSafePass_2026
# DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}
# DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11
SECRET_KEY=SzuperTitkosVéletlenszerűKaraktersorozat_Amit_Csak_Te_Tudsz_2026
SECRET_KEY="2e34bfff3e448c6d6f75cbacf65035e3a833e6648053e8e97ce37c6fa82b6525"
ALGORITHM="HS256"
SENDGRID_API_KEY=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
FROM_EMAIL=info@profibot.hu

View File

@@ -0,0 +1,26 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_port=5432
# --- ADATBÁZIS ELÉRÉS (A Python kódnak) ---
# Formátum: postgresql+asyncpg://FELHASZNÁLÓ:JELSZÓ@KONTÉNER_NÉV:PORT/ADATBÁZIS_NÉV
DATABASE_URL=postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder
# --- REDIS (A session kezeléshez) ---
REDIS_URL=redis://redis:6379/0
# PgAdmin
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Backend (FastAPI)
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
#
# DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
DATABASE_URL=postgresql+asyncpg://${APP_DB_USER}:${APP_DB_PASSWORD}@${APP_DB_HOST}:${APP_DB_PORT}/${APP_DB_NAME}
# DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,52 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
# --- APP KAPCSOLAT (A Pythonnak) ---
# Itt szedjük szét a hosszú sort:
APP_DB_HOST=postgres-db
APP_DB_PORT=5432
APP_DB_NAME=service_finder
APP_DB_USER=service_finder_app
APP_DB_PASSWORD=Uj_BIZTONSÁGOS_JELSZÓ
# DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
DATABASE_URL="postgresql+asyncpg://service_finder_app:AppJelszo@postgres-db:5432/service_finder"
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# MinIO (NAS) elérés - A POSTGRES adatait használjuk az egyszerűségért
MINIO_ROOT_USER=${POSTGRES_USER}
MINIO_ROOT_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ENDPOINT=minio:9000
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,46 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=PASSWORD111
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
# Ez a sor építi fel a teljes kapcsolati stringet a fenti változókból.
# Így ha feljebb átírod a jelszót, a program is tudni fogja.
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=szuper_titkos_random_string_amit_senki_nem_tud
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# 3. INFRASTRUKTÚRA & CACHE
# ==========================================
# A Redis belső hálózati elérése (a container neve 'redis')
REDIS_URL=redis://redis:6379/0
# ==========================================
# 4. MONITORING & TOOLS
# ==========================================
# PgAdmin belépés
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# ==========================================
# 2. BIZTONSÁG & AUTH (FastAPI)
# ==========================================
# A JWT tokenek aláírásához. Ezt SOHA ne add ki senkinek!
# Generálj egy újat linuxon ezzel: openssl rand -hex 32
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Code Server (ha használod a webes VS Code-ot)
CODE_SERVER_PASSWORD=Megeszemakalapom11

View File

@@ -0,0 +1,28 @@
# ==========================================
# 1. ADATBÁZIS KONFIGURÁCIÓ (PostgreSQL)
# ==========================================
POSTGRES_USER=kincses
POSTGRES_PASSWORD=MiskociA74
POSTGRES_DB=service_finder
POSTGRES_HOST=postgres-db
POSTGRES_port=5432
# --- ADATBÁZIS ELÉRÉS (A Python kódnak) ---
# Formátum: postgresql+asyncpg://FELHASZNÁLÓ:JELSZÓ@KONTÉNER_NÉV:PORT/ADATBÁZIS_NÉV
# DATABASE_URL=postgresql+asyncpg://kincses:MiskociA74@postgres-db:5432/service_finder
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# --- REDIS (A session kezeléshez) ---
REDIS_URL=redis://redis:6379/0
# PgAdmin
PGADMIN_EMAIL=kincses@gmail.com
PGADMIN_PASSWORD=MiskociA74
# Backend (FastAPI)
SECRET_KEY=GeneraltRandomHosszuStringAmiTitkos
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
CODE_SERVER_PASSWORD=Megeszemakalapom11

Some files were not shown because too many files have changed in this diff Show More