first commit

This commit is contained in:
Miksa 2026-05-22 23:05:37 +02:00
commit b0ee86c924
20 changed files with 5655 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
npm-debug.log
.git
.gitignore
.env
uploads

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
PORT=3000
JWT_SECRET=elevator-manager-secret
DATABASE_URL=postgresql://lift_admin:lift_secret@127.0.0.1:5432/lift_manager

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
uploads/
data/store.json
.env

239
README.md Normal file
View File

@ -0,0 +1,239 @@
# LiftEllenőr
Magyar nyelvű liftellenőrzési és naplózási rendszer technikusok számára.
Az alkalmazás főbb részei:
- nyilvános landing oldal: `/`
- bejelentkezés: `/bejelentkezes`
- regisztráció: `/regisztracio`
- technikusi kezelőfelület: `/alkalmazas`
- kereshető naplóoldal: `/naplo`
## Funkciók
- JWT alapú hitelesítés
- PostgreSQL adatbázis
- külön kezelt eszközök és naplóbejegyzések
- modalos új eszköz / új napló folyamat
- PDF csatolmányok feltöltése egy naplóhoz
- közelgő ellenőrzési határidők műszerfala
- Adminer webes adatbázis-kezelő a stack részeként
- nagy mennyiségű tesztadat generálása seed scripttel
## Stack
A `compose.yaml` alapján a rendszer ezekből a szolgáltatásokból áll:
- `frontend`
Nginx konténer, a statikus frontend kiszolgálására és az API proxyzására.
- `backend`
Node.js + Express alkalmazás, API-val és hitelesítéssel.
- `postgres`
PostgreSQL adatbázis a felhasználók, eszközök, naplók és fájlmetaadatok tárolására.
- `adminer`
Webes adatbázis-kezelő a PostgreSQL gyors böngészéséhez és ellenőrzéséhez.
## Portok
- alkalmazás: `http://localhost:8080`
- PostgreSQL: `localhost:5432`
- Adminer: `http://localhost:8090`
## Előfeltételek
- Node.js 22 körüli verzió
- npm
- Podman Compose vagy Docker Compose
## Gyors indítás
1. Függőségek telepítése:
```bash
npm install
```
2. A teljes stack indítása:
```bash
podman compose up -d
```
Ha Docker Compose-t használsz:
```bash
docker compose up -d
```
3. Nyisd meg az alkalmazást:
```text
http://localhost:8080
```
## Stack kezelése
Indítás:
```bash
podman compose up -d
```
Leállítás:
```bash
podman compose down
```
Leállítás kötetekkel együtt:
```bash
podman compose down -v
```
Logok megtekintése:
```bash
podman compose logs -f
```
Csak a backend logjai:
```bash
podman compose logs -f backend
```
## Adminer használata
Az Adminer a `http://localhost:8090` címen érhető el.
Bejelentkezési adatok:
- rendszer: `PostgreSQL`
- szerver: `postgres`
Ha a böngészőből közvetlenül a hoston futó PostgreSQL-re csatlakozol, használhatod a `127.0.0.1` címet is, de a compose stacken belül a szolgáltatás neve `postgres`.
- felhasználó: `lift_admin`
- jelszó: `lift_secret`
- adatbázis: `lift_manager`
Adminerben hasznos műveletek:
- `users` tábla ellenőrzése
- `elevators` tábla böngészése
- `logs` és `log_files` kapcsolatok megtekintése
- seed után gyors ellenőrzés, hogy a tömeges tesztadat bekerült-e
## Seed adatok
A projekt tartalmaz egy seed scriptet sok tesztadat előállításához:
```bash
npm run seed
```
Ez több útvonalat, technikust, eszközt és sok kapcsolódó naplóbejegyzést hoz létre.
Alap teszt jelszó:
```text
teszt123
```
Teljes újratöltéshez, a meglévő adatok törlésével:
```bash
npm run seed:reset
```
Egyedi mennyiségekkel:
```bash
node scripts/seed.js --reset --routes 8 --users-per-route 4 --elevators-per-route 20 --logs-per-elevator 16
```
Elérhető paraméterek:
- `--reset`
Törli a meglévő adatokat seed előtt.
- `--routes`
Hány technikusi útvonal készüljön.
- `--users-per-route`
Hány felhasználó készüljön útvonalanként.
- `--elevators-per-route`
Hány eszköz készüljön útvonalanként.
- `--logs-per-elevator`
Hány napló készüljön eszközönként.
## Fejlesztői használat
Ha nem konténerből akarod indítani a backendet:
1. gondoskodj róla, hogy a PostgreSQL fusson
2. telepítsd a függőségeket
3. indítsd a szervert
```bash
npm install
npm start
```
Alapértelmezett backend port:
```text
http://localhost:3000
```
Ebben az esetben a frontend proxyzása nélkül a teljes alkalmazás használatához továbbra is a compose-os Nginx a legegyszerűbb út.
## Környezeti változók
Az alapértelmezett értékek az `.env.example` fájlban vannak összegyűjtve.
Fontos változók:
- `PORT`
- `JWT_SECRET`
- `DATABASE_URL`
- `SEED_PASSWORD`
Opcionális. Ezzel felülírható a seedelt felhasználók alap jelszava.
## Adatmodell röviden
- `users`
Technikusi felhasználók és útvonalak.
- `elevators`
Az eszközök aktuális állapota és következő ellenőrzési adatai.
- `logs`
Történeti naplóbejegyzések, eszközhöz kapcsolva.
- `log_files`
A naplóbejegyzésekhez tartozó PDF fájlmetaadatok.
## Tipikus használati folyamat
1. Indítsd el a stack-et.
2. Ha kell, seedeld az adatbázist `npm run seed:reset` paranccsal.
3. Nyisd meg az alkalmazást a `http://localhost:8080` címen.
4. Jelentkezz be vagy regisztrálj.
5. Az `/alkalmazas` oldalon kezeld az eszközöket és hozz létre új naplóbejegyzéseket.
6. A `/naplo` oldalon keresd és szűrd a bejegyzéseket.
7. Adminerben ellenőrizd az adatokat, ha fejlesztés vagy hibakeresés közben szükséges.
## Fontos fájlok
- `server.js`
Express route-ok, auth, adatbázis-műveletek
- `compose.yaml`
frontend + backend + postgres + adminer stack
- `nginx/default.conf`
frontend route-ok és API proxy
- `public/index.html`
landing oldal
- `public/app.html`
kezelőfelület
- `public/logs.html`
külön naplóoldal
- `public/style.css`
központi stíluslap
- `scripts/seed.js`
tesztadat-generáló script

54
compose.yaml Normal file
View File

@ -0,0 +1,54 @@
services:
frontend:
image: docker.io/library/nginx:1.27-alpine
container_name: lift-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "8080:80"
volumes:
- ./public:/usr/share/nginx/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
backend:
image: docker.io/library/node:22-alpine
container_name: lift-backend
restart: unless-stopped
depends_on:
- postgres
working_dir: /app
environment:
PORT: 3000
JWT_SECRET: elevator-manager-secret
DATABASE_URL: postgresql://lift_admin:lift_secret@postgres:5432/lift_manager
command: sh -c "npm ci --omit=dev && npm start"
volumes:
- ./package.json:/app/package.json:ro
- ./package-lock.json:/app/package-lock.json:ro
- ./server.js:/app/server.js:ro
- ./public:/app/public:ro
- ./data:/app/data
- ./uploads:/app/uploads
postgres:
image: docker.io/library/postgres:16-alpine
container_name: lift-postgres
restart: unless-stopped
environment:
POSTGRES_DB: lift_manager
POSTGRES_USER: lift_admin
POSTGRES_PASSWORD: lift_secret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
adminer:
image: adminer
restart: always
ports:
- 8090:8080
volumes:
postgres_data:

67
nginx/default.conf Normal file
View File

@ -0,0 +1,67 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index login.html;
location = / {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /bejelentkezes {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /regisztracio {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /alkalmazas {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /naplo {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
expires off;
try_files $uri $uri/ =404;
}
}

1785
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "elevator-man",
"version": "1.0.0",
"description": "Magyar nyelvű liftellenőrzési kezelő JWT auth-tal, PDF feltöltéssel és PostgreSQL adatbázissal.",
"main": "server.js",
"scripts": {
"start": "node server.js",
"seed": "node scripts/seed.js",
"seed:reset": "node scripts/seed.js --reset"
},
"keywords": [
"elevator",
"inspection",
"jwt",
"bcrypt",
"postgres"
],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.1.1",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"pg": "^8.13.1"
}
}

189
public/app.html Normal file
View File

@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alkalmazás | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-5" />
</head>
<body>
<div id="pageShell">
<main id="appShell">
<div id="topBar" class="panel app-topbar">
<div class="topbar-heading">
<img class="topbar-logo" src="/logo.png" alt="" aria-hidden="true" />
<div>
<h1 id="welcomeText" class="page-title">Kezelőfelület</h1>
<p id="routeText" class="page-text"></p>
</div>
</div>
<div class="panel-actions">
<a class="ghost-button" href="/naplo">Napló megnyitása</a>
<button id="logoutButton" class="ghost-button" type="button">Kijelentkezés</button>
</div>
</div>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Határidők</h2>
<p>A bejelentkezett technikus útvonalához tartozó, a következő 7 napban esedékes ellenőrzések.</p>
</div>
</div>
<div id="dashboardCards" class="dashboard-grid"></div>
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Eszközök</h2>
<p>Külön kezelt eszközlista, amelynek az állapota új naplóbejegyzésekkel frissül.</p>
</div>
<div class="panel-actions">
<button id="openDeviceModalButton" class="secondary-button" type="button">Új eszköz</button>
<button id="openLogModalButton" class="primary-button" type="button">Új napló</button>
</div>
</div>
<div id="deviceList" class="entries-grid"></div>
</section>
</main>
</div>
<div id="deviceModal" class="modal-backdrop hidden">
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="deviceModalTitle">
<div class="modal-head">
<div class="section-heading">
<h2 id="deviceModalTitle">Új eszköz</h2>
<p id="deviceModalSubtitle">Új lift vagy berendezés rögzítése a technikusi útvonalhoz.</p>
</div>
<button id="closeDeviceModalButton" class="ghost-button modal-close" type="button">Bezárás</button>
</div>
<form id="deviceForm">
<input id="deviceEditId" name="elevatorId" type="hidden" />
<div class="form-grid">
<div class="field-wrap">
<label for="deviceName">Eszköz neve</label>
<input id="deviceName" name="name" type="text" required />
</div>
<div class="field-wrap">
<label for="deviceSerialNumber">Gyári szám</label>
<input id="deviceSerialNumber" name="serialNumber" type="text" required />
</div>
<div class="field-wrap field-wide">
<label for="deviceLocation">Helyszín</label>
<input id="deviceLocation" name="location" type="text" required />
</div>
<div class="field-wrap">
<label for="deviceRouteName">Technikusi útvonal</label>
<input id="deviceRouteName" class="readonly-input" name="routeName" type="text" readonly />
</div>
<div class="field-wrap">
<label for="deviceStatus">Aktuális állapot</label>
<select id="deviceStatus" name="status">
<option value="rendben">Rendben</option>
<option value="figyelmeztetes">Figyelmeztetés</option>
<option value="lejart">Lejárt</option>
</select>
</div>
<div class="field-wrap">
<label for="deviceInspectionIntervalDays">Ellenőrzési ciklus napokban</label>
<input id="deviceInspectionIntervalDays" name="inspectionIntervalDays" type="number" min="1" value="30" required />
</div>
<div class="field-wrap">
<label for="deviceNextInspectionDate">Következő ellenőrzés</label>
<input id="deviceNextInspectionDate" name="nextInspectionDate" type="date" />
</div>
</div>
<div class="form-actions">
<div class="inline-actions">
<button id="deviceSubmitButton" class="primary-button" type="submit">Eszköz mentése</button>
<button id="cancelDeviceModalButton" class="ghost-button" type="button">Mégse</button>
</div>
<p id="deviceMessage" class="feedback-text"></p>
</div>
</form>
</div>
</div>
<div id="logModal" class="modal-backdrop hidden">
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="logModalTitle">
<div class="modal-head">
<div class="section-heading">
<h2 id="logModalTitle">Új naplóbejegyzés</h2>
<p>Egy meglévő eszköz állapotának frissítése új ellenőrzési naplóval.</p>
</div>
<button id="closeLogModalButton" class="ghost-button modal-close" type="button">Bezárás</button>
</div>
<form id="logForm">
<div class="form-grid">
<div class="field-wrap field-wide">
<label for="logElevatorId">Eszköz kiválasztása</label>
<select id="logElevatorId" name="elevatorId" required></select>
</div>
<div class="field-wrap field-wide">
<div id="logElevatorSummary" class="summary-card">
Válassz ki egy eszközt a naplóbejegyzéshez.
</div>
</div>
<div class="field-wrap">
<label for="logStatus">Állapot</label>
<select id="logStatus" name="status">
<option value="rendben">Rendben</option>
<option value="figyelmeztetes">Figyelmeztetés</option>
<option value="lejart">Lejárt</option>
</select>
</div>
<div class="field-wrap">
<label for="logInspectionDate">Ellenőrzés dátuma</label>
<input id="logInspectionDate" name="inspectionDate" type="date" required />
</div>
<div class="field-wrap">
<label for="logInspectionIntervalDays">Ellenőrzési ciklus napokban</label>
<input id="logInspectionIntervalDays" name="inspectionIntervalDays" type="number" min="1" value="30" required />
</div>
<div class="field-wrap">
<label for="logNextInspectionDate">Következő ellenőrzés</label>
<input id="logNextInspectionDate" name="nextInspectionDate" type="date" required />
</div>
<div class="field-wrap field-wide">
<label for="logNotes">Megjegyzés</label>
<textarea id="logNotes" name="notes" rows="4" placeholder="Rövid állapotleírás vagy teendő"></textarea>
</div>
<div class="field-wrap field-wide">
<label for="logPdfs">PDF csatolmányok</label>
<input id="logPdfs" name="pdfs" type="file" accept="application/pdf,.pdf" multiple />
</div>
</div>
<div class="form-actions">
<div class="inline-actions">
<button id="logSubmitButton" class="primary-button" type="submit">Bejegyzés mentése</button>
<button id="openDeviceFromLogButton" class="secondary-button" type="button">Új eszköz létrehozása</button>
<button id="cancelLogModalButton" class="ghost-button" type="button">Mégse</button>
</div>
<p id="logMessage" class="feedback-text"></p>
</div>
</form>
</div>
</div>
<script src="/app.js?v=20260522-5"></script>
</body>
</html>

543
public/app.js Normal file
View File

@ -0,0 +1,543 @@
const state = {
user: null,
devices: [],
dashboardElevators: [],
countdownIntervalId: null,
activeModal: null,
selectedLogElevatorId: null
};
const elements = {
welcomeText: document.getElementById("welcomeText"),
routeText: document.getElementById("routeText"),
logoutButton: document.getElementById("logoutButton"),
dashboardCards: document.getElementById("dashboardCards"),
deviceList: document.getElementById("deviceList"),
openDeviceModalButton: document.getElementById("openDeviceModalButton"),
openLogModalButton: document.getElementById("openLogModalButton"),
deviceModal: document.getElementById("deviceModal"),
logModal: document.getElementById("logModal"),
deviceForm: document.getElementById("deviceForm"),
deviceEditId: document.getElementById("deviceEditId"),
deviceModalTitle: document.getElementById("deviceModalTitle"),
deviceModalSubtitle: document.getElementById("deviceModalSubtitle"),
deviceRouteName: document.getElementById("deviceRouteName"),
deviceName: document.getElementById("deviceName"),
deviceSerialNumber: document.getElementById("deviceSerialNumber"),
deviceLocation: document.getElementById("deviceLocation"),
deviceStatus: document.getElementById("deviceStatus"),
deviceInspectionIntervalDays: document.getElementById("deviceInspectionIntervalDays"),
deviceNextInspectionDate: document.getElementById("deviceNextInspectionDate"),
deviceMessage: document.getElementById("deviceMessage"),
closeDeviceModalButton: document.getElementById("closeDeviceModalButton"),
cancelDeviceModalButton: document.getElementById("cancelDeviceModalButton"),
logForm: document.getElementById("logForm"),
logElevatorId: document.getElementById("logElevatorId"),
logElevatorSummary: document.getElementById("logElevatorSummary"),
logStatus: document.getElementById("logStatus"),
logInspectionDate: document.getElementById("logInspectionDate"),
logInspectionIntervalDays: document.getElementById("logInspectionIntervalDays"),
logNextInspectionDate: document.getElementById("logNextInspectionDate"),
logMessage: document.getElementById("logMessage"),
closeLogModalButton: document.getElementById("closeLogModalButton"),
cancelLogModalButton: document.getElementById("cancelLogModalButton"),
openDeviceFromLogButton: document.getElementById("openDeviceFromLogButton")
};
bindEvents();
bootPage();
function bindEvents() {
elements.logoutButton.addEventListener("click", logout);
elements.openDeviceModalButton.addEventListener("click", () => openDeviceModal());
elements.openLogModalButton.addEventListener("click", () => openLogModal());
elements.deviceForm.addEventListener("submit", handleDeviceSave);
elements.logForm.addEventListener("submit", handleLogSave);
elements.closeDeviceModalButton.addEventListener("click", closeActiveModal);
elements.cancelDeviceModalButton.addEventListener("click", closeActiveModal);
elements.closeLogModalButton.addEventListener("click", closeActiveModal);
elements.cancelLogModalButton.addEventListener("click", closeActiveModal);
elements.openDeviceFromLogButton.addEventListener("click", handleOpenDeviceFromLog);
elements.logElevatorId.addEventListener("change", handleLogElevatorChange);
elements.logInspectionDate.addEventListener("change", syncLogNextInspectionDate);
elements.logInspectionIntervalDays.addEventListener("input", syncLogNextInspectionDate);
elements.deviceList.addEventListener("click", handleDeviceListClick);
elements.deviceModal.addEventListener("click", handleModalBackdropClick);
elements.logModal.addEventListener("click", handleModalBackdropClick);
window.addEventListener("keydown", handleWindowKeydown);
window.addEventListener("pageshow", handlePageShow);
}
async function bootPage() {
setToday(elements.logInspectionDate);
syncLogNextInspectionDate();
renderDevices();
renderDashboard();
try {
const response = await apiFetch("/api/me");
state.user = response.user;
fillUserHeader();
elements.deviceRouteName.value = state.user?.routeName || "";
await Promise.allSettled([loadDashboard(), loadDevices()]);
} catch (error) {
if (error.status === 401) {
window.location.href = "/bejelentkezes";
return;
}
elements.welcomeText.textContent = "Betöltési hiba";
elements.routeText.textContent = "A munkamenet ellenőrzése nem sikerült. Frissítsd az oldalt vagy jelentkezz be újra.";
setFeedback(elements.logMessage, error.message, "error");
}
}
function fillUserHeader() {
const name = state.user?.fullName || "Felhasználó";
const route = state.user?.routeName || "Nincs megadva";
elements.welcomeText.textContent = "Kezelőfelület";
elements.routeText.textContent = `${name} · Technikusi útvonal: ${route}`;
}
async function loadDashboard() {
try {
const response = await apiFetch("/api/dashboard");
state.dashboardElevators = response.elevators || [];
renderDashboard();
} catch (error) {
state.dashboardElevators = [];
renderDashboard();
setFeedback(elements.logMessage, error.message, "error");
}
}
async function loadDevices() {
try {
const response = await apiFetch("/api/elevators");
state.devices = response.elevators || [];
renderDevices();
renderLogElevatorOptions(state.selectedLogElevatorId);
} catch (error) {
state.devices = [];
renderDevices();
renderLogElevatorOptions(null);
setFeedback(elements.deviceMessage, error.message, "error");
}
}
function renderDashboard() {
if (!state.dashboardElevators.length) {
elements.dashboardCards.innerHTML = `
<article class="dashboard-card empty-card">
<h3>Nincs közelgő határidő</h3>
<p>A következő 7 napban esedékes ellenőrzések itt jelennek meg.</p>
</article>
`;
clearCountdownTimer();
return;
}
elements.dashboardCards.innerHTML = state.dashboardElevators
.map((elevator) => {
const countdown = calculateCountdown(elevator.nextInspectionDate);
const statusClass = countdown.expired ? "danger-chip" : countdown.warning ? "warning-chip" : "success-chip";
return `
<article class="dashboard-card">
<div class="card-topline">
<span class="status-chip ${statusClass}">${countdown.label}</span>
<span class="serial-chip">${escapeHtml(elevator.serialNumber)}</span>
</div>
<h3>${escapeHtml(elevator.name)}</h3>
<p>${escapeHtml(elevator.location)}</p>
<p>Legutóbbi állapot: ${humanizeStatus(elevator.lastStatus)}</p>
<div class="countdown-box" data-next-date="${elevator.nextInspectionDate}">
${countdown.text}
</div>
<p class="muted-text">Következő ellenőrzés: ${formatDate(elevator.nextInspectionDate)}</p>
</article>
`;
})
.join("");
clearCountdownTimer();
state.countdownIntervalId = window.setInterval(updateCountdownBoxes, 1000);
}
function renderDevices() {
if (!state.devices.length) {
elements.deviceList.innerHTML = `
<article class="entry-card empty-card">
<h3>Még nincs rögzített eszköz</h3>
<p>Hozz létre egy új eszközt, majd adj hozzá naplóbejegyzéseket az állapot frissítéséhez.</p>
</article>
`;
elements.openLogModalButton.disabled = true;
return;
}
elements.openLogModalButton.disabled = false;
elements.deviceList.innerHTML = state.devices
.map((device) => `
<article class="entry-card device-card">
<div class="card-topline">
<span class="status-chip ${statusClassName(device.lastStatus)}">${humanizeStatus(device.lastStatus)}</span>
<span class="serial-chip">${escapeHtml(device.serialNumber)}</span>
</div>
<h3>${escapeHtml(device.name)}</h3>
<p><strong>Helyszín:</strong> ${escapeHtml(device.location)}</p>
<p><strong>Útvonal:</strong> ${escapeHtml(device.routeName)}</p>
<p><strong>Utolsó ellenőrzés:</strong> ${formatDate(device.lastInspectionDate)}</p>
<p><strong>Következő ellenőrzés:</strong> ${formatDate(device.nextInspectionDate)}</p>
<p><strong>Ciklus:</strong> ${escapeHtml(device.inspectionIntervalDays)} nap</p>
<div class="device-actions">
<button class="secondary-button" type="button" data-action="log" data-elevator-id="${device.id}">Új napló</button>
<button class="ghost-button" type="button" data-action="edit" data-elevator-id="${device.id}">Szerkesztés</button>
</div>
</article>
`)
.join("");
}
function renderLogElevatorOptions(preferredElevatorId) {
if (!state.devices.length) {
state.selectedLogElevatorId = null;
elements.logElevatorId.innerHTML = `<option value="">Előbb hozz létre egy eszközt</option>`;
elements.logElevatorSummary.textContent = "A naplózáshoz előbb egy eszközt kell létrehozni.";
return;
}
const fallbackId = preferredElevatorId || state.selectedLogElevatorId || state.devices[0].id;
const resolvedId = state.devices.some((device) => device.id === Number(fallbackId)) ? Number(fallbackId) : state.devices[0].id;
state.selectedLogElevatorId = resolvedId;
elements.logElevatorId.innerHTML = state.devices
.map(
(device) => `
<option value="${device.id}" ${device.id === resolvedId ? "selected" : ""}>
${escapeHtml(device.name)} · ${escapeHtml(device.serialNumber)}
</option>
`
)
.join("");
renderSelectedElevatorSummary(resolvedId);
}
function renderSelectedElevatorSummary(elevatorId) {
const device = state.devices.find((entry) => entry.id === Number(elevatorId));
if (!device) {
elements.logElevatorSummary.textContent = "Válassz ki egy eszközt a naplóbejegyzéshez.";
return;
}
elements.logElevatorSummary.innerHTML = `
<strong>${escapeHtml(device.name)}</strong><br />
Gyári szám: ${escapeHtml(device.serialNumber)}<br />
Helyszín: ${escapeHtml(device.location)}<br />
Utolsó állapot: ${humanizeStatus(device.lastStatus)}<br />
Következő ellenőrzés: ${formatDate(device.nextInspectionDate)}
`;
}
function updateCountdownBoxes() {
document.querySelectorAll(".countdown-box").forEach((box) => {
const countdown = calculateCountdown(box.dataset.nextDate);
box.textContent = countdown.text;
});
}
function clearCountdownTimer() {
if (state.countdownIntervalId) {
window.clearInterval(state.countdownIntervalId);
state.countdownIntervalId = null;
}
}
function handlePageShow() {
if (state.user) {
Promise.allSettled([loadDashboard(), loadDevices()]);
}
}
function handleDeviceListClick(event) {
const button = event.target.closest("button[data-action]");
if (!button) {
return;
}
const elevatorId = Number(button.dataset.elevatorId);
if (button.dataset.action === "log") {
openLogModal(elevatorId);
return;
}
if (button.dataset.action === "edit") {
openDeviceModal(elevatorId);
}
}
function handleLogElevatorChange() {
const elevatorId = Number(elements.logElevatorId.value);
state.selectedLogElevatorId = Number.isInteger(elevatorId) && elevatorId > 0 ? elevatorId : null;
renderSelectedElevatorSummary(state.selectedLogElevatorId);
const device = getSelectedElevator();
if (device) {
elements.logInspectionIntervalDays.value = device.inspectionIntervalDays || 30;
syncLogNextInspectionDate();
}
}
function handleModalBackdropClick(event) {
if (event.target === event.currentTarget) {
closeActiveModal();
}
}
function handleWindowKeydown(event) {
if (event.key === "Escape" && state.activeModal) {
closeActiveModal();
}
}
function handleOpenDeviceFromLog() {
closeActiveModal();
openDeviceModal(null, true);
}
async function handleDeviceSave(event) {
event.preventDefault();
const elevatorId = Number(elements.deviceEditId.value || 0);
const payload = {
name: elements.deviceName.value,
serialNumber: elements.deviceSerialNumber.value,
location: elements.deviceLocation.value,
routeName: elements.deviceRouteName.value,
status: elements.deviceStatus.value,
inspectionIntervalDays: elements.deviceInspectionIntervalDays.value,
nextInspectionDate: elements.deviceNextInspectionDate.value || "",
lastInspectionDate: elements.deviceForm.dataset.lastInspectionDate || ""
};
try {
const response = await apiFetch(elevatorId ? `/api/elevators/${elevatorId}` : "/api/elevators", {
method: elevatorId ? "PUT" : "POST",
body: JSON.stringify(payload)
});
setFeedback(elements.deviceMessage, response.message, "success");
await Promise.allSettled([loadDevices(), loadDashboard()]);
closeActiveModal();
if (elements.deviceForm.dataset.returnToLog === "true" && response.elevator?.id) {
openLogModal(response.elevator.id);
}
} catch (error) {
setFeedback(elements.deviceMessage, error.message, "error");
}
}
async function handleLogSave(event) {
event.preventDefault();
const formData = new FormData(elements.logForm);
try {
const response = await apiFetch("/api/logs", {
method: "POST",
body: formData,
isFormData: true
});
setFeedback(elements.logMessage, response.message, "success");
await Promise.allSettled([loadDashboard(), loadDevices()]);
closeActiveModal();
} catch (error) {
setFeedback(elements.logMessage, error.message, "error");
}
}
async function logout() {
try {
const response = await apiFetch("/api/logout", { method: "POST" });
window.location.href = response.redirectPath;
} catch (_error) {
window.location.href = "/bejelentkezes";
}
}
function openDeviceModal(elevatorId = null, returnToLog = false) {
const device = state.devices.find((entry) => entry.id === Number(elevatorId));
elements.deviceForm.reset();
elements.deviceForm.dataset.returnToLog = returnToLog ? "true" : "false";
elements.deviceForm.dataset.lastInspectionDate = normalizeDateInput(device?.lastInspectionDate);
elements.deviceEditId.value = device?.id || "";
elements.deviceRouteName.value = state.user?.routeName || "";
elements.deviceInspectionIntervalDays.value = device?.inspectionIntervalDays || 30;
elements.deviceStatus.value = device?.lastStatus || "rendben";
elements.deviceNextInspectionDate.value = normalizeDateInput(device?.nextInspectionDate);
elements.deviceName.value = device?.name || "";
elements.deviceSerialNumber.value = device?.serialNumber || "";
elements.deviceLocation.value = device?.location || "";
elements.deviceModalTitle.textContent = device ? "Eszköz szerkesztése" : "Új eszköz";
elements.deviceModalSubtitle.textContent = device
? "Az eszköz alapadatainak frissítése. Az állapotot naplóbejegyzéssel is módosíthatod."
: "Új lift vagy berendezés rögzítése a technikusi útvonalhoz.";
setFeedback(elements.deviceMessage, "", "");
showModal(elements.deviceModal);
}
function openLogModal(elevatorId = null) {
if (!state.devices.length) {
openDeviceModal();
setFeedback(elements.deviceMessage, "A naplózáshoz előbb létre kell hoznod egy eszközt.", "error");
return;
}
elements.logForm.reset();
setFeedback(elements.logMessage, "", "");
setToday(elements.logInspectionDate);
elements.logStatus.value = "rendben";
renderLogElevatorOptions(elevatorId);
elements.logElevatorId.value = String(state.selectedLogElevatorId || "");
handleLogElevatorChange();
showModal(elements.logModal);
}
function showModal(modalElement) {
closeActiveModal();
state.activeModal = modalElement;
modalElement.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function closeActiveModal() {
if (!state.activeModal) {
return;
}
state.activeModal.classList.add("hidden");
state.activeModal = null;
document.body.classList.remove("modal-open");
}
function getSelectedElevator() {
return state.devices.find((device) => device.id === Number(state.selectedLogElevatorId)) || null;
}
async function apiFetch(url, options = {}) {
const headers = options.isFormData ? {} : { "Content-Type": "application/json" };
const response = await fetch(url, {
method: options.method || "GET",
credentials: "include",
headers,
body: options.body
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json()
: { message: "A szerver nem JSON választ adott vissza." };
if (!response.ok) {
const error = new Error(data.message || "A kérés nem sikerült.");
error.status = response.status;
throw error;
}
return data;
}
function syncLogNextInspectionDate() {
const inspectionDate = elements.logInspectionDate.value;
const intervalDays = Number(elements.logInspectionIntervalDays.value || 30);
if (!inspectionDate) {
return;
}
const baseDate = new Date(`${inspectionDate}T00:00:00`);
baseDate.setDate(baseDate.getDate() + intervalDays);
elements.logNextInspectionDate.value = baseDate.toISOString().slice(0, 10);
}
function setToday(element) {
element.value = new Date().toISOString().slice(0, 10);
}
function calculateCountdown(nextInspectionDate) {
const normalizedDate = normalizeDateInput(nextInspectionDate);
if (!normalizedDate) {
return {
expired: false,
warning: false,
label: "Nincs dátum",
text: "Nincs megadott határidő"
};
}
const targetTime = new Date(`${normalizedDate}T23:59:59`).getTime();
const diff = targetTime - Date.now();
const expired = diff < 0;
const absoluteDiff = Math.abs(diff);
const totalHours = Math.floor(absoluteDiff / (1000 * 60 * 60));
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
const warning = !expired && days <= 7;
const label = expired ? "Lejárt" : warning ? "Hamarosan esedékes" : "Rendben";
const prefix = expired ? "Túllépés" : "Hátralévő idő";
return {
expired,
warning,
label,
text: `${prefix}: ${days} nap ${hours} óra`
};
}
function humanizeStatus(status) {
return status === "rendben" ? "Rendben" : status === "figyelmeztetes" ? "Figyelmeztetés" : "Lejárt";
}
function statusClassName(status) {
return status === "rendben" ? "success-chip" : status === "figyelmeztetes" ? "warning-chip" : "danger-chip";
}
function formatDate(value) {
const normalizedDate = normalizeDateInput(value);
return normalizedDate ? new Date(`${normalizedDate}T00:00:00`).toLocaleDateString("hu-HU") : "Nincs dátum";
}
function normalizeDateInput(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return value.includes("T") ? value.slice(0, 10) : value;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? "" : parsedDate.toISOString().slice(0, 10);
}
function setFeedback(element, message, type) {
element.textContent = message;
element.className = `feedback-text ${type ? `feedback-${type}` : ""}`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

42
public/auth.js Normal file
View File

@ -0,0 +1,42 @@
const form = document.getElementById("authForm");
const feedback = document.getElementById("authMessage");
const isRegisterPage = window.location.pathname === "/regisztracio";
form?.addEventListener("submit", handleSubmit);
async function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
const endpoint = isRegisterPage ? "/api/register" : "/api/login";
try {
const response = await fetch(endpoint, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "A kérés nem sikerült.");
}
setFeedback(data.message, "success");
window.setTimeout(() => {
window.location.href = data.redirectPath;
}, 500);
} catch (error) {
setFeedback(error.message, "error");
}
}
function setFeedback(message, type) {
feedback.textContent = message;
feedback.className = `feedback-text ${type ? `feedback-${type}` : ""}`;
}

116
public/index.html Normal file
View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Csödényi Felvonó Felülvizsgáló | Biztonság és szakértelem</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel landing-header">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">Csödényi Felvonó Felülvizsgáló</p>
<p class="brand-subtitle">Időszakos vizsgálatok, biztonságtechnikai ellenőrzések és szakértői támogatás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link landing-login-link" href="/bejelentkezes">Technikusi belépés</a>
</nav>
</header>
<main id="mainLayout" class="landing-layout">
<section class="landing-hero panel">
<div class="landing-hero-copy">
<p class="landing-kicker">Felvonó-felülvizsgálat kompromisszumok nélkül</p>
<h1 id="mainTitle" class="landing-title">Biztonság és szakértelem a legfelsőbb szinteken</h1>
<p id="heroText" class="landing-lead">
A lift nem pusztán kényelmi eszköz, hanem a mindennapi biztonság és a zavartalan közlekedés alapja.
A Csödényi Felvonó Felülvizsgálónál azért dolgozunk, hogy az Ön épületében üzemelő felvonók
megbízhatóan, szabályosan és kiszámíthatóan működjenek.
</p>
<p class="landing-lead">
Több éves szakmai tapasztalattal, naprakész jogszabályi ismeretekkel és korszerű diagnosztikai
eszközökkel végezzük személy- és teherfelvonók időszakos vizsgálatát, biztonságtechnikai ellenőrzését
és üzembe helyezés előtti felülvizsgálatát.
</p>
</div>
<img class="landing-hero-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Miért minket válasszon?</h2>
<p>Megbízható partner társasházak, irodaházak és ipari létesítmények számára.</p>
</div>
</div>
<div class="landing-feature-grid">
<article class="landing-feature-card">
<h3>Maximális precizitás</h3>
<p>A biztonság területén nem ismerünk kompromisszumot: minden ellenőrzést részletesen, dokumentáltan végzünk el.</p>
</article>
<article class="landing-feature-card">
<h3>Gyors és rugalmas ügyintézés</h3>
<p>Az épület működéséhez igazodunk, hogy a vizsgálatok a lehető legkevesebb fennakadással járjanak.</p>
</article>
<article class="landing-feature-card">
<h3>Teljes körű dokumentáció</h3>
<p>Átlátható, hatósági előírásoknak megfelelő jegyzőkönyveket és jól követhető szakmai összefoglalókat adunk át.</p>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Miben állunk rendelkezésére?</h2>
<p>Röviden összefoglaltuk, hogyan tudjuk támogatni az üzemeltetőket és közös képviselőket.</p>
</div>
</div>
<div class="landing-service-list">
<article class="landing-service-item">
<h3>Törvényi előírás szerinti időszakos ellenőrzések</h3>
<p>Negyedéves, féléves és éves biztonságtechnikai felülvizsgálatok a jogszabályi megfelelés érdekében.</p>
</article>
<article class="landing-service-item">
<h3>Üzembe helyezés előtti vizsgálat</h3>
<p>Új vagy modernizált liftek hatósági átadásának szakértői előkészítése és támogatása.</p>
</article>
<article class="landing-service-item">
<h3>Kockázatértékelés és állapotfelmérés</h3>
<p>Részletes műszaki helyzetkép, javítási javaslatokkal és korszerűsítési irányokkal.</p>
</article>
<article class="landing-service-item">
<h3>Szakértői tanácsadás</h3>
<p>Segítség közös képviselőknek, üzemeltetőknek és cégvezetőknek a vonatkozó szabványok és követelmények értelmezésében.</p>
</article>
</div>
</section>
<section class="landing-cta panel">
<div class="section-heading">
<h2>Közeleg a kötelező felülvizsgálat?</h2>
<p>
A biztonságos liftüzemeltetés és a szabályozások betartása komoly felelősség. Ne hagyja az utolsó
pillanatra a vizsgálat megszervezését.
</p>
</div>
<p class="landing-cta-text">
A Csödényi Felvonó Felülvizsgáló gyorsan, pontosan és a hatályos előírások szigorú követésével dolgozik,
hogy a működés zavartalan, a dokumentáció pedig kifogástalan legyen.
</p>
<div class="landing-contact-row">
<span>Telefon: +36 30 123 4567</span>
<span>E-mail: info@csodenyifelulvizsgalo.hu</span>
<span>Webalkalmazás: technikusok részére</span>
</div>
</section>
</main>
</div>
</body>
</html>

51
public/login.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bejelentkezés | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">LiftEllenőr</p>
<p class="brand-subtitle">Technikusi hozzáférés és vizsgálati naplózás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link" href="/">Főoldal</a>
<a class="nav-link nav-link-active" href="/bejelentkezes">Bejelentkezés</a>
<a class="nav-link" href="/regisztracio">Regisztráció</a>
</nav>
</header>
<main id="mainLayout">
<section class="panel auth-panel">
<div class="section-heading">
<h1 class="page-title">Bejelentkezés</h1>
<p class="page-text">A rendszer használatához jelentkezz be a felhasználói adataiddal.</p>
</div>
<form id="authForm" class="auth-form">
<label for="username">Felhasználónév</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">Jelszó</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<button class="primary-button" type="submit">Bejelentkezés</button>
</form>
<p id="authMessage" class="feedback-text"></p>
<p class="auth-link-row">Nincs még hozzáférésed? <a href="/regisztracio">Regisztráció</a></p>
</section>
</main>
</div>
<script src="/auth.js?v=20260522-1"></script>
</body>
</html>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

60
public/logs.html Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Napló | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-5" />
</head>
<body>
<div id="pageShell">
<main id="appShell">
<div id="topBar" class="panel app-topbar">
<div class="topbar-heading">
<img class="topbar-logo" src="/logo.png" alt="" aria-hidden="true" />
<div>
<h1 id="welcomeText" class="page-title">Napló</h1>
<p id="routeText" class="page-text"></p>
</div>
</div>
<div class="panel-actions">
<a class="ghost-button" href="/alkalmazas">Vissza az alkalmazáshoz</a>
<button id="logoutButton" class="ghost-button" type="button">Kijelentkezés</button>
</div>
</div>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Bejegyzések</h2>
<p>Kereshető és rendezhető ellenőrzési napló a teljes útvonalhoz.</p>
</div>
</div>
<div id="entryControls">
<div class="field-wrap">
<label for="searchInput">Keresés</label>
<input id="searchInput" type="text" placeholder="Lift, helyszín, gyári szám, technikus" />
</div>
<div class="field-wrap">
<label for="sortSelect">Rendezés</label>
<select id="sortSelect">
<option value="inspectionDateDesc">Ellenőrzés dátuma szerint csökkenő</option>
<option value="inspectionDateAsc">Ellenőrzés dátuma szerint növekvő</option>
<option value="nextInspectionAsc">Következő ellenőrzés szerint növekvő</option>
<option value="nextInspectionDesc">Következő ellenőrzés szerint csökkenő</option>
<option value="elevatorNameAsc">Lift neve szerint</option>
<option value="statusAsc">Állapot szerint</option>
</select>
</div>
</div>
<div id="entriesList" class="entries-grid"></div>
</section>
</main>
</div>
<script src="/logs.js?v=20260522-5"></script>
</body>
</html>

261
public/logs.js Normal file
View File

@ -0,0 +1,261 @@
const state = {
user: null,
logs: [],
logsLoaded: false,
initialLogRetryCount: 0,
searchActivated: false
};
const elements = {
welcomeText: document.getElementById("welcomeText"),
routeText: document.getElementById("routeText"),
logoutButton: document.getElementById("logoutButton"),
entriesList: document.getElementById("entriesList"),
searchInput: document.getElementById("searchInput"),
sortSelect: document.getElementById("sortSelect")
};
bindEvents();
bootPage();
function bindEvents() {
elements.logoutButton.addEventListener("click", logout);
elements.searchInput.addEventListener("input", handleSearchInput);
elements.sortSelect.addEventListener("change", loadLogs);
window.addEventListener("pageshow", handlePageShow);
}
async function bootPage() {
state.logsLoaded = false;
state.initialLogRetryCount = 0;
state.searchActivated = false;
elements.searchInput.value = "";
renderLogs();
try {
const response = await apiFetch("/api/me");
state.user = response.user;
fillUserHeader();
await loadLogs(true);
} catch (error) {
if (error.status === 401) {
window.location.href = "/bejelentkezes";
return;
}
elements.welcomeText.textContent = "Betöltési hiba";
elements.routeText.textContent = "A napló nem tölthető be. Frissítsd az oldalt vagy jelentkezz be újra.";
renderError(error.message);
}
}
function fillUserHeader() {
const name = state.user?.fullName || "Felhasználó";
const route = state.user?.routeName || "Nincs megadva";
elements.welcomeText.textContent = "Napló";
elements.routeText.textContent = `${name} · Technikusi útvonal: ${route}`;
}
async function loadLogs(isInitialLoad = false) {
try {
const sortBy = elements.sortSelect.value || "inspectionDateDesc";
const response = await apiFetch(`/api/logs?sortBy=${encodeURIComponent(sortBy)}`);
state.logs = Array.isArray(response.logs)
? response.logs.map((log) => ({
...log,
files: Array.isArray(log.files) ? log.files : []
}))
: [];
state.logsLoaded = true;
renderLogs();
if (isInitialLoad && !state.logs.length) {
scheduleInitialLogRetry();
}
} catch (error) {
state.logs = [];
state.logsLoaded = true;
renderError(error.message);
if (isInitialLoad) {
scheduleInitialLogRetry();
}
}
}
function renderLogs() {
if (!state.logsLoaded) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Betöltés</h3>
<p>A bejegyzések betöltése folyamatban van.</p>
</article>
`;
return;
}
const query = state.searchActivated ? elements.searchInput.value.trim().toLowerCase() : "";
const visibleLogs = state.logs.filter((log) => searchableLog(log).includes(query));
if (!visibleLogs.length) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Nincs találat</h3>
<p>Módosítsd a keresést, vagy hozz létre új ellenőrzési bejegyzést az alkalmazás oldalon.</p>
</article>
`;
return;
}
elements.entriesList.innerHTML = visibleLogs
.map((log) => {
const fileMarkup = log.files.length
? log.files
.map(
(file) => `
<a class="file-link" href="${file.downloadPath}">
${escapeHtml(file.originalName)}
</a>
`
)
.join("")
: `<span class="muted-text">Nincs PDF csatolmány</span>`;
return `
<article class="entry-card">
<div class="card-topline">
<span class="status-chip ${statusClassName(log.status)}">${humanizeStatus(log.status)}</span>
<span class="muted-text">${formatDate(log.inspectionDate)}</span>
</div>
<h3>${escapeHtml(log.elevatorName)}</h3>
<p><strong>Gyári szám:</strong> ${escapeHtml(log.serialNumber)}</p>
<p><strong>Helyszín:</strong> ${escapeHtml(log.location)}</p>
<p><strong>Útvonal:</strong> ${escapeHtml(log.routeName)}</p>
<p><strong>Technikus:</strong> ${escapeHtml(log.technicianName)}</p>
<p><strong>Következő ellenőrzés:</strong> ${formatDate(log.nextInspectionDate)}</p>
<p><strong>Megjegyzés:</strong> ${escapeHtml(log.notes || "Nincs megjegyzés.")}</p>
<div class="file-list">${fileMarkup}</div>
</article>
`;
})
.join("");
}
function renderError(message) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Hiba</h3>
<p>${escapeHtml(message)}</p>
</article>
`;
}
function scheduleInitialLogRetry() {
if (state.initialLogRetryCount >= 3 || state.logs.length) {
return;
}
state.initialLogRetryCount += 1;
window.setTimeout(() => {
state.logsLoaded = false;
renderLogs();
loadLogs(false);
}, 500 * state.initialLogRetryCount);
}
function handleSearchInput() {
state.searchActivated = true;
renderLogs();
}
function handlePageShow() {
if (state.user) {
state.logsLoaded = false;
renderLogs();
loadLogs();
}
}
async function logout() {
try {
const response = await apiFetch("/api/logout", { method: "POST" });
window.location.href = response.redirectPath;
} catch (_error) {
window.location.href = "/bejelentkezes";
}
}
async function apiFetch(url, options = {}) {
const headers = options.isFormData ? {} : { "Content-Type": "application/json" };
const response = await fetch(url, {
method: options.method || "GET",
credentials: "include",
headers,
body: options.body
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json()
: { message: "A szerver nem JSON választ adott vissza." };
if (!response.ok) {
const error = new Error(data.message || "A kérés nem sikerült.");
error.status = response.status;
throw error;
}
return data;
}
function searchableLog(log) {
return [
log.elevatorName,
log.serialNumber,
log.location,
log.routeName,
log.technicianName,
log.status,
log.notes
]
.join(" ")
.toLowerCase();
}
function humanizeStatus(status) {
return status === "rendben" ? "Rendben" : status === "figyelmeztetes" ? "Figyelmeztetés" : "Lejárt";
}
function statusClassName(status) {
return status === "rendben" ? "success-chip" : status === "figyelmeztetes" ? "warning-chip" : "danger-chip";
}
function formatDate(value) {
const normalizedDate = normalizeDateInput(value);
return normalizedDate ? new Date(`${normalizedDate}T00:00:00`).toLocaleDateString("hu-HU") : "Nincs dátum";
}
function normalizeDateInput(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return value.includes("T") ? value.slice(0, 10) : value;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? "" : parsedDate.toISOString().slice(0, 10);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

57
public/register.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regisztráció | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">LiftEllenőr</p>
<p class="brand-subtitle">Technikusi hozzáférés és vizsgálati naplózás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link" href="/">Főoldal</a>
<a class="nav-link" href="/bejelentkezes">Bejelentkezés</a>
<a class="nav-link nav-link-active" href="/regisztracio">Regisztráció</a>
</nav>
</header>
<main id="mainLayout">
<section class="panel auth-panel">
<div class="section-heading">
<h1 class="page-title">Regisztráció</h1>
<p class="page-text">Új technikusi hozzáférés létrehozása.</p>
</div>
<form id="authForm" class="auth-form">
<label for="fullName">Teljes név</label>
<input id="fullName" name="fullName" type="text" required />
<label for="routeName">Technikusi útvonal</label>
<input id="routeName" name="routeName" type="text" placeholder="Például: Dél-Pest" required />
<label for="username">Felhasználónév</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">Jelszó</label>
<input id="password" name="password" type="password" autocomplete="new-password" required />
<button class="secondary-button" type="submit">Regisztráció mentése</button>
</form>
<p id="authMessage" class="feedback-text"></p>
<p class="auth-link-row">Van már fiókod? <a href="/bejelentkezes">Bejelentkezés</a></p>
</section>
</main>
</div>
<script src="/auth.js?v=20260522-1"></script>
</body>
</html>

788
public/style.css Normal file
View File

@ -0,0 +1,788 @@
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--line: #d9dee7;
--line-strong: #c8d0dc;
--text: #1f2937;
--muted: #6b7280;
--accent: #2563eb;
--accent-dark: #1d4ed8;
--warning: #b45309;
--danger: #b91c1c;
--success: #15803d;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--radius: 12px;
}
* {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
font-family: "Segoe UI", Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--text);
background:
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, #f7faff, var(--bg) 18%);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
border: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.75rem 0.85rem;
background: #fff;
color: var(--text);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
textarea {
resize: vertical;
}
label {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
#pageShell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 1.5rem 0 2.5rem;
}
#heroText,
.panel-head p,
#routeText,
.muted-text,
.feedback-text,
.entry-card p,
.dashboard-card p,
.auth-link-row {
color: var(--muted);
}
#mainLayout,
#appShell {
display: grid;
gap: 1rem;
}
.landing-layout {
gap: 1.25rem;
}
.auth-panel {
max-width: 560px;
margin: 0 auto;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.landing-header {
position: sticky;
top: 1rem;
z-index: 2;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98)),
var(--panel);
backdrop-filter: blur(8px);
}
#siteHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
border-color: #d6e2f7;
background:
linear-gradient(135deg, rgba(239, 245, 255, 0.96), rgba(255, 255, 255, 0.98)),
var(--panel);
}
.brand-block {
display: inline-flex;
align-items: center;
gap: 0.9rem;
min-width: 0;
}
.brand-link {
color: inherit;
text-decoration: none;
}
.brand-copy {
min-width: 0;
}
.brand-logo {
width: 52px;
height: 52px;
object-fit: contain;
border-radius: 14px;
flex: 0 0 auto;
background: #fff;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.14);
}
.brand-name {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--accent-dark);
}
.brand-subtitle {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.landing-header .brand-block {
gap: 1rem;
}
.landing-header .brand-copy {
display: grid;
gap: 0.2rem;
}
.landing-header .brand-logo {
width: 68px;
height: 68px;
padding: 0.2rem;
border-radius: 18px;
}
.landing-header .brand-name {
line-height: 1.15;
}
.top-nav {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.nav-link {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.8rem;
color: var(--text);
background: #fff;
text-decoration: none;
font-weight: 600;
}
.nav-link:hover {
background: #f8fafc;
}
.nav-link-active {
border-color: var(--line-strong);
background: #eef4ff;
color: var(--accent-dark);
}
.section-heading {
display: grid;
gap: 0.2rem;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.landing-kicker {
margin: 0 0 0.6rem;
color: var(--accent-dark);
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.landing-title {
margin: 0;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 1.05;
max-width: 12ch;
}
.landing-lead {
margin: 0;
font-size: 1.02rem;
color: var(--muted);
max-width: 68ch;
}
.page-text {
margin: 0;
color: var(--muted);
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.panel-actions,
.inline-actions,
.device-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.panel-head h2,
.entry-card h3,
.dashboard-card h3 {
margin: 0;
}
.panel-head h2 {
font-size: 1.15rem;
}
.tab-button,
.ghost-button,
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
padding: 0.7rem 1rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.tab-button,
.ghost-button {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.primary-button {
background: var(--accent);
color: #fff;
}
.primary-button:hover {
background: var(--accent-dark);
}
.secondary-button {
background: #eef4ff;
color: var(--accent-dark);
border: 1px solid #cfe0ff;
}
.secondary-button:hover {
background: #e3edff;
}
.ghost-button:hover,
.tab-button:hover {
background: #f8fafc;
}
.app-topbar .ghost-button {
border-color: #bfd3fb;
background: rgba(255, 255, 255, 0.88);
}
.app-topbar .ghost-button:hover {
background: #eff5ff;
}
.compact-button {
padding: 0.55rem 0.8rem;
font-size: 0.92rem;
}
.auth-form,
#logForm {
display: grid;
gap: 0.85rem;
}
#deviceForm {
display: grid;
gap: 0.85rem;
}
.hidden {
display: none !important;
}
#topBar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.app-topbar {
border-color: #cfe0ff;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(255, 255, 255, 0.96) 28%),
var(--panel);
}
.topbar-heading {
display: flex;
align-items: center;
gap: 0.9rem;
min-width: 0;
}
.topbar-logo {
width: 50px;
height: 50px;
object-fit: contain;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
padding: 0.2rem;
flex: 0 0 auto;
}
.landing-hero {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(240px, 0.8fr);
gap: 1rem;
align-items: stretch;
padding: 1.5rem;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.1), transparent 32%),
linear-gradient(180deg, #ffffff, #f7f9fc);
}
.landing-hero-copy {
display: grid;
gap: 1rem;
}
.landing-hero-logo {
width: min(100%, 320px);
max-height: 320px;
justify-self: center;
align-self: center;
object-fit: contain;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 36px rgba(37, 99, 235, 0.16);
padding: 0.6rem;
}
.hero-sidecard {
display: grid;
align-content: start;
gap: 0.9rem;
padding: 1rem;
border: 1px solid #dbe4f0;
border-radius: 14px;
background: linear-gradient(180deg, #eef4ff, #ffffff);
}
.hero-sidecard-label {
margin: 0;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-dark);
}
.hero-sidecard-text {
margin: 0;
color: var(--text);
font-weight: 600;
}
.landing-login-link {
padding: 0.45rem 0.75rem;
font-size: 0.92rem;
border-color: #bfd3fb;
color: var(--accent-dark);
background: rgba(255, 255, 255, 0.9);
}
.landing-feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.landing-feature-card,
.landing-service-item {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
.landing-feature-card {
padding: 1rem;
}
.landing-feature-card h3,
.landing-service-item h3 {
margin: 0 0 0.55rem;
}
.landing-feature-card p,
.landing-service-item p,
.landing-cta-text {
margin: 0;
color: var(--muted);
}
.landing-service-list {
display: grid;
gap: 0.9rem;
}
.landing-service-item {
padding: 1rem 1rem 1rem 1.15rem;
border-left: 4px solid #cfe0ff;
}
.landing-cta {
display: grid;
gap: 1rem;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(255, 255, 255, 1) 38%),
#fff;
}
.landing-contact-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
color: var(--text);
font-weight: 600;
}
.dashboard-grid,
.entries-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.dashboard-card,
.entry-card {
padding: 1rem;
border-radius: 10px;
background: #fff;
border: 1px solid var(--line);
min-height: 100%;
}
.empty-card {
display: grid;
place-items: center;
text-align: center;
min-height: 180px;
}
.card-topline {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.8rem;
flex-wrap: wrap;
}
.status-chip,
.serial-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.3rem 0.65rem;
font-size: 0.82rem;
font-weight: 600;
}
.serial-chip {
background: #f3f4f6;
color: var(--text);
}
.success-chip {
background: #eaf7ef;
color: var(--success);
}
.warning-chip {
background: #fff4e5;
color: var(--warning);
}
.danger-chip {
background: #fdecec;
color: var(--danger);
}
.countdown-box {
margin: 1rem 0 0.75rem;
padding: 0.85rem 0.95rem;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--line);
font-size: 1rem;
font-weight: 700;
}
.form-grid,
#entryControls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
#entryControls {
margin-bottom: 0.75rem;
}
.field-wrap {
display: grid;
gap: 0.45rem;
}
.readonly-input {
background: #f8fafc;
color: var(--muted);
}
.field-wide {
grid-column: 1 / -1;
}
.form-actions {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.feedback-text {
min-height: 1.25rem;
margin: 0;
}
.feedback-success {
color: var(--success);
}
.feedback-error {
color: var(--danger);
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.summary-card {
padding: 0.95rem 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--text);
line-height: 1.6;
}
.device-card {
display: grid;
}
.device-actions {
margin-top: 1rem;
}
.modal-open {
overflow: hidden;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.52);
}
.modal-panel {
width: min(760px, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
border: 1px solid #d6e2f7;
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98)),
#fff;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
padding: 1.25rem;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-close {
flex: 0 0 auto;
}
.file-link {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.7rem;
border-radius: 8px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--accent-dark);
text-decoration: none;
}
.file-link:hover {
background: #eef4ff;
border-color: #cfe0ff;
}
.auth-link-row {
margin: 0.4rem 0 0;
}
.auth-link-row a {
color: var(--accent-dark);
font-weight: 600;
text-decoration: none;
}
.auth-link-row a:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.landing-hero,
.landing-feature-grid,
.dashboard-grid,
.entries-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
#pageShell {
width: min(100% - 1rem, 1180px);
padding-top: 1rem;
}
#siteHeader,
#topBar,
.panel-head,
.form-actions,
.modal-head {
display: grid;
align-items: start;
}
.brand-block,
.topbar-heading {
align-items: start;
}
.landing-hero,
.landing-feature-grid,
.form-grid,
#entryControls,
.dashboard-grid,
.entries-grid {
grid-template-columns: 1fr;
}
#mainTitle {
max-width: none;
font-size: 1.6rem;
}
.landing-title {
max-width: none;
}
.landing-hero-logo {
width: min(100%, 220px);
max-height: 220px;
}
.brand-logo,
.topbar-logo {
width: 44px;
height: 44px;
}
}

419
scripts/seed.js Normal file
View File

@ -0,0 +1,419 @@
const bcrypt = require("bcrypt");
const { Pool } = require("pg");
const DATABASE_URL = process.env.DATABASE_URL || "postgresql://lift_admin:lift_secret@127.0.0.1:5432/lift_manager";
const SALT_ROUNDS = 10;
const DEFAULT_PASSWORD = process.env.SEED_PASSWORD || "teszt123";
const RUN_TOKEN = Date.now().toString(36).slice(-6);
const ROUTES = [
"Észak-Buda",
"Dél-Buda",
"Észak-Pest",
"Dél-Pest",
"Belváros",
"Külváros",
"Keleti zóna",
"Nyugati zóna"
];
const FIRST_NAMES = [
"Ádám",
"Bence",
"Csaba",
"Dávid",
"Erika",
"Ferenc",
"Gábor",
"Hanna",
"István",
"Judit",
"Katalin",
"Levente",
"Márk",
"Noémi",
"Orsolya",
"Péter",
"Réka",
"Sándor",
"Tamás",
"Zoltán"
];
const LAST_NAMES = [
"Kovács",
"Szabó",
"Tóth",
"Nagy",
"Varga",
"Kiss",
"Molnár",
"Németh",
"Farkas",
"Balogh",
"Takács",
"Juhász"
];
const STREETS = [
"Kossuth Lajos utca",
"Rákóczi út",
"Bartók Béla út",
"Alkotás utca",
"Petőfi Sándor utca",
"Szent István körút",
"Andrássy út",
"József Attila utca",
"Kerepesi út",
"Váci út",
"Móricz Zsigmond körtér",
"Fehérvári út"
];
const BUILDING_TYPES = [
"Társasház",
"Irodaház",
"Rendelőintézet",
"Szálloda",
"Kereskedelmi központ",
"Logisztikai csarnok",
"Önkormányzati épület",
"Parkolóház"
];
const STATUS_VALUES = ["rendben", "rendben", "rendben", "figyelmeztetes", "lejart"];
const NOTE_TEMPLATES = [
"Rendszeres időszakos ellenőrzés, jelentős eltérés nem tapasztalható.",
"Ajtózár finombeállítás javasolt a következő szerviz alkalmával.",
"A gépház tiszta, az üzemi paraméterek megfelelő tartományban vannak.",
"Kisebb kopás látható a vezetősínek környezetében, megfigyelés javasolt.",
"A vészvilágítás működik, a dokumentáció rendben.",
"Szintbeállítás megfelelő, utasoldali panasz nem érkezett.",
"Biztonsági áramkör ellenőrizve, működés megfelelő.",
"Esztétikai sérülések láthatók a fülkében, műszaki hibát nem okoznak."
];
const pool = new Pool({
connectionString: DATABASE_URL
});
const options = parseArgs(process.argv.slice(2));
main().catch((error) => {
console.error("A seed futtatása sikertelen:", error.message);
process.exitCode = 1;
}).finally(async () => {
await pool.end();
});
async function main() {
await initializeDatabase();
if (options.reset) {
await resetDatabase();
}
const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, SALT_ROUNDS);
const client = await pool.connect();
try {
await client.query("BEGIN");
const createdUsers = [];
const createdElevators = [];
let createdLogs = 0;
for (let routeIndex = 0; routeIndex < options.routeCount; routeIndex += 1) {
const routeName = ROUTES[routeIndex % ROUTES.length];
for (let userIndex = 0; userIndex < options.usersPerRoute; userIndex += 1) {
const user = await createUser(client, routeName, routeIndex, userIndex, passwordHash);
createdUsers.push(user);
}
const routeUsers = createdUsers.filter((user) => user.route_name === routeName);
for (let elevatorIndex = 0; elevatorIndex < options.elevatorsPerRoute; elevatorIndex += 1) {
const elevator = await createElevator(client, routeName, routeIndex, elevatorIndex);
createdElevators.push(elevator);
createdLogs += await createLogsForElevator(client, elevator, routeUsers, elevatorIndex, options.logsPerElevator);
}
}
await client.query("COMMIT");
console.log("Seed kész.");
console.log(`Felhasználók: ${createdUsers.length}`);
console.log(`Eszközök: ${createdElevators.length}`);
console.log(`Naplóbejegyzések: ${createdLogs}`);
console.log(`Teszt jelszó: ${DEFAULT_PASSWORD}`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
function parseArgs(args) {
const parsed = {
reset: args.includes("--reset"),
routeCount: 6,
usersPerRoute: 3,
elevatorsPerRoute: 18,
logsPerElevator: 14
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const nextValue = args[index + 1];
if (arg === "--routes") {
parsed.routeCount = toPositiveInt(nextValue, parsed.routeCount);
}
if (arg === "--users-per-route") {
parsed.usersPerRoute = toPositiveInt(nextValue, parsed.usersPerRoute);
}
if (arg === "--elevators-per-route") {
parsed.elevatorsPerRoute = toPositiveInt(nextValue, parsed.elevatorsPerRoute);
}
if (arg === "--logs-per-elevator") {
parsed.logsPerElevator = toPositiveInt(nextValue, parsed.logsPerElevator);
}
}
return parsed;
}
function toPositiveInt(value, fallback) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
async function initializeDatabase() {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
route_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS elevators (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
serial_number TEXT NOT NULL UNIQUE,
location TEXT NOT NULL,
route_name TEXT NOT NULL,
last_inspection_date DATE,
next_inspection_date DATE,
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
last_status TEXT NOT NULL DEFAULT 'rendben',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL,
elevator_name TEXT NOT NULL,
serial_number TEXT NOT NULL,
location TEXT NOT NULL,
route_name TEXT NOT NULL,
inspection_date DATE NOT NULL,
next_inspection_date DATE NOT NULL,
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
status TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
technician_name TEXT NOT NULL,
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS log_files (
id SERIAL PRIMARY KEY,
log_id INTEGER NOT NULL REFERENCES logs(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type TEXT NOT NULL
);
`);
await pool.query(`
ALTER TABLE logs
ADD COLUMN IF NOT EXISTS elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL;
`);
}
async function resetDatabase() {
await pool.query(`
TRUNCATE TABLE log_files, logs, elevators, users
RESTART IDENTITY CASCADE;
`);
}
async function createUser(client, routeName, routeIndex, userIndex, passwordHash) {
const firstName = FIRST_NAMES[(routeIndex * 3 + userIndex) % FIRST_NAMES.length];
const lastName = LAST_NAMES[(routeIndex * 5 + userIndex) % LAST_NAMES.length];
const fullName = `${lastName} ${firstName}`;
const username = normalizeUsername(`${lastName}.${firstName}.${routeIndex + 1}.${userIndex + 1}.${RUN_TOKEN}`);
const result = await client.query(
`INSERT INTO users (username, password_hash, full_name, route_name)
VALUES ($1, $2, $3, $4)
RETURNING id, username, full_name, route_name`,
[username, passwordHash, fullName, routeName]
);
return result.rows[0];
}
async function createElevator(client, routeName, routeIndex, elevatorIndex) {
const buildingType = BUILDING_TYPES[(routeIndex + elevatorIndex) % BUILDING_TYPES.length];
const street = STREETS[(routeIndex * 7 + elevatorIndex) % STREETS.length];
const buildingNumber = 10 + ((routeIndex * 13 + elevatorIndex * 3) % 89);
const elevatorName = `${buildingType} ${routeIndex + 1}/${elevatorIndex + 1}. lift`;
const serialNumber = buildSerialNumber(routeIndex, elevatorIndex);
const location = `Budapest, ${street} ${buildingNumber}.`;
const result = await client.query(
`INSERT INTO elevators (
name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at`,
[elevatorName, serialNumber, location, routeName, null, null, 30, "rendben"]
);
return result.rows[0];
}
async function createLogsForElevator(client, elevator, routeUsers, elevatorIndex, logCount) {
const createdLogs = [];
const latestLogProfile = buildLatestLogProfile(elevatorIndex);
for (let logIndex = 0; logIndex < logCount; logIndex += 1) {
const isLatestLog = logIndex === logCount - 1;
const logProfile = isLatestLog ? latestLogProfile : buildHistoricalLogProfile(elevatorIndex, logIndex);
const user = routeUsers[(elevatorIndex + logIndex) % routeUsers.length];
const inspectionDate = dateToIso(logProfile.inspectionDate);
const nextInspectionDate = dateToIso(logProfile.nextInspectionDate);
const result = await client.query(
`INSERT INTO logs (
elevator_id, elevator_name, serial_number, location, route_name, inspection_date,
next_inspection_date, inspection_interval_days, status, notes,
technician_name, created_by_user_id, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`,
[
elevator.id,
elevator.name,
elevator.serial_number,
elevator.location,
elevator.route_name,
inspectionDate,
nextInspectionDate,
logProfile.inspectionIntervalDays,
logProfile.status,
logProfile.notes,
user.full_name,
user.id,
logProfile.createdAt.toISOString()
]
);
createdLogs.push(result.rows[0].id);
}
await client.query(
`UPDATE elevators
SET last_inspection_date = $1,
next_inspection_date = $2,
inspection_interval_days = $3,
last_status = $4,
updated_at = $5
WHERE id = $6`,
[
dateToIso(latestLogProfile.inspectionDate),
dateToIso(latestLogProfile.nextInspectionDate),
latestLogProfile.inspectionIntervalDays,
latestLogProfile.status,
latestLogProfile.createdAt.toISOString(),
elevator.id
]
);
return createdLogs.length;
}
function buildLatestLogProfile(elevatorIndex) {
const today = startOfToday();
const nextInspectionOffset = elevatorIndex % 10;
const intervalDays = 30 + ((elevatorIndex % 4) * 15);
const inspectionDate = addDays(today, -((elevatorIndex % 18) + 3));
const nextInspectionDate = addDays(today, nextInspectionOffset);
const status = nextInspectionOffset <= 1 ? "figyelmeztetes" : "rendben";
return {
inspectionDate,
nextInspectionDate,
inspectionIntervalDays: intervalDays,
status,
notes: NOTE_TEMPLATES[elevatorIndex % NOTE_TEMPLATES.length],
createdAt: new Date(`${dateToIso(inspectionDate)}T09:00:00Z`)
};
}
function buildHistoricalLogProfile(elevatorIndex, logIndex) {
const intervalDays = 30 + (((elevatorIndex + logIndex) % 4) * 15);
const inspectionDate = addDays(startOfToday(), -((logIndex + 2) * intervalDays));
const nextInspectionDate = addDays(inspectionDate, intervalDays);
const status = STATUS_VALUES[(elevatorIndex + logIndex) % STATUS_VALUES.length];
return {
inspectionDate,
nextInspectionDate,
inspectionIntervalDays: intervalDays,
status,
notes: NOTE_TEMPLATES[(elevatorIndex + logIndex) % NOTE_TEMPLATES.length],
createdAt: new Date(`${dateToIso(inspectionDate)}T08:30:00Z`)
};
}
function buildSerialNumber(routeIndex, elevatorIndex) {
const routePart = String(routeIndex + 1).padStart(2, "0");
const elevatorPart = String(elevatorIndex + 1).padStart(3, "0");
return `LIFT-${RUN_TOKEN}-${routePart}-${elevatorPart}`;
}
function normalizeUsername(value) {
return value
.normalize("NFD")
.replaceAll(/[\u0300-\u036f]/g, "")
.replaceAll(/[^a-zA-Z0-9.]/g, "")
.toLowerCase();
}
function startOfToday() {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function addDays(date, days) {
const copy = new Date(date.getTime());
copy.setUTCDate(copy.getUTCDate() + days);
return copy;
}
function dateToIso(date) {
return date.toISOString().slice(0, 10);
}

944
server.js Normal file
View File

@ -0,0 +1,944 @@
const express = require("express");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { Pool } = require("pg");
const app = express();
const PORT = Number(process.env.PORT || 3000);
const JWT_SECRET = process.env.JWT_SECRET || "elevator-manager-secret";
const DATABASE_URL = process.env.DATABASE_URL || "postgresql://lift_admin:lift_secret@127.0.0.1:5432/lift_manager";
const SALT_ROUNDS = 10;
const MAX_FILES = 8;
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const COOKIE_NAME = "liftToken";
const PUBLIC_DIR = path.join(__dirname, "public");
const DATA_DIR = path.join(__dirname, "data");
const UPLOAD_DIR = path.join(__dirname, "uploads");
const LEGACY_DATA_FILE = path.join(DATA_DIR, "store.json");
const pool = new Pool({
connectionString: DATABASE_URL
});
ensureDirectories();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(PUBLIC_DIR));
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
const extension = path.extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${crypto.randomUUID()}${extension}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: MAX_FILE_SIZE,
files: MAX_FILES
},
fileFilter: (_req, file, cb) => {
const isPdf = file.mimetype === "application/pdf" || path.extname(file.originalname).toLowerCase() === ".pdf";
cb(isPdf ? null : new Error("Csak PDF fájl tölthető fel."), isPdf);
}
});
app.get("/", (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
});
app.get("/bejelentkezes", requireGuestPage, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "login.html"));
});
app.get("/regisztracio", requireGuestPage, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "register.html"));
});
app.get("/alkalmazas", requirePageAuth, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "app.html"));
});
app.get("/naplo", requirePageAuth, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "logs.html"));
});
app.get("/api/health", async (_req, res) => {
const result = await pool.query("SELECT NOW() AS now");
res.json({ message: "A rendszer működik.", dbTime: result.rows[0].now });
});
app.post("/api/register", async (req, res) => {
try {
const username = req.body.username?.trim().toLowerCase();
const password = req.body.password?.trim();
const fullName = req.body.fullName?.trim();
const routeName = req.body.routeName?.trim();
if (!username || !password || !fullName || !routeName) {
return res.status(400).json({ message: "Minden regisztrációs mező kötelező." });
}
const existingUser = await pool.query("SELECT id FROM users WHERE username = $1", [username]);
if (existingUser.rowCount) {
return res.status(409).json({ message: "Ez a felhasználónév már foglalt." });
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
await pool.query(
`INSERT INTO users (username, password_hash, full_name, route_name)
VALUES ($1, $2, $3, $4)`,
[username, passwordHash, fullName, routeName]
);
return res.status(201).json({
message: "Sikeres regisztráció. Most már bejelentkezhetsz.",
redirectPath: "/bejelentkezes"
});
} catch (_error) {
return res.status(500).json({ message: "Hiba történt a regisztráció során." });
}
});
app.post("/api/login", async (req, res) => {
try {
const username = req.body.username?.trim().toLowerCase();
const password = req.body.password?.trim();
if (!username || !password) {
return res.status(400).json({ message: "Felhasználónév és jelszó kötelező." });
}
const result = await pool.query(
`SELECT id, username, password_hash, full_name, route_name
FROM users
WHERE username = $1`,
[username]
);
if (!result.rowCount) {
return res.status(401).json({ message: "Hibás bejelentkezési adatok." });
}
const user = mapUserRow(result.rows[0]);
const passwordMatches = await bcrypt.compare(password, result.rows[0].password_hash);
if (!passwordMatches) {
return res.status(401).json({ message: "Hibás bejelentkezési adatok." });
}
setAuthCookie(res, createToken(user));
return res.json({
message: "Sikeres bejelentkezés.",
redirectPath: "/alkalmazas",
user
});
} catch (_error) {
return res.status(500).json({ message: "Hiba történt a bejelentkezés során." });
}
});
app.post("/api/logout", (_req, res) => {
clearAuthCookie(res);
return res.json({ message: "Sikeres kijelentkezés.", redirectPath: "/bejelentkezes" });
});
app.get("/api/me", authenticateRequest, (req, res) => {
res.json({ user: req.user });
});
app.get("/api/dashboard", authenticateRequest, async (req, res) => {
const result = await pool.query(
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at
FROM elevators
WHERE route_name = $1
AND next_inspection_date IS NOT NULL
AND next_inspection_date >= CURRENT_DATE
AND next_inspection_date <= CURRENT_DATE + INTERVAL '7 days'
ORDER BY next_inspection_date ASC NULLS LAST, id DESC`,
[req.user.routeName]
);
res.json({
routeName: req.user.routeName,
elevators: result.rows.map(mapElevatorRow)
});
});
app.get("/api/elevators", authenticateRequest, async (req, res) => {
const result = await pool.query(
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at
FROM elevators
WHERE route_name = $1
ORDER BY name ASC, id DESC`,
[req.user.routeName]
);
res.json({
routeName: req.user.routeName,
elevators: result.rows.map(mapElevatorRow)
});
});
app.post("/api/elevators", authenticateRequest, async (req, res) => {
const payload = normalizeElevatorPayload(req.body, req.user);
const validationMessage = validateElevatorPayload(payload);
if (validationMessage) {
return res.status(400).json({ message: validationMessage });
}
const result = await pool.query(
`INSERT INTO elevators (
name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at`,
[
payload.name,
payload.serialNumber,
payload.location,
payload.routeName,
payload.lastInspectionDate,
payload.nextInspectionDate,
payload.inspectionIntervalDays,
payload.status
]
);
res.status(201).json({
message: "Az eszköz sikeresen létre lett hozva.",
elevator: mapElevatorRow(result.rows[0])
});
});
app.put("/api/elevators/:elevatorId", authenticateRequest, async (req, res) => {
const elevatorId = Number(req.params.elevatorId);
if (!Number.isInteger(elevatorId) || elevatorId <= 0) {
return res.status(400).json({ message: "Érvénytelen eszközazonosító." });
}
const payload = normalizeElevatorPayload(req.body, req.user);
const validationMessage = validateElevatorPayload(payload);
if (validationMessage) {
return res.status(400).json({ message: validationMessage });
}
const result = await pool.query(
`UPDATE elevators
SET name = $1,
serial_number = $2,
location = $3,
route_name = $4,
last_inspection_date = $5,
next_inspection_date = $6,
inspection_interval_days = $7,
last_status = $8,
updated_at = NOW()
WHERE id = $9 AND route_name = $10
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at`,
[
payload.name,
payload.serialNumber,
payload.location,
payload.routeName,
payload.lastInspectionDate,
payload.nextInspectionDate,
payload.inspectionIntervalDays,
payload.status,
elevatorId,
req.user.routeName
]
);
if (!result.rowCount) {
return res.status(404).json({ message: "A keresett eszköz nem található." });
}
res.json({
message: "Az eszköz adatai frissültek.",
elevator: mapElevatorRow(result.rows[0])
});
});
app.get("/api/logs", authenticateRequest, async (req, res) => {
const search = req.query.search?.trim().toLowerCase() || "";
const sortBy = req.query.sortBy?.trim() || "inspectionDateDesc";
const params = [req.user.routeName];
let whereClause = "WHERE l.route_name = $1";
if (search) {
params.push(`%${search}%`);
whereClause += ` AND LOWER(CONCAT_WS(' ', l.elevator_name, l.serial_number, l.location, l.route_name, l.technician_name, l.notes, l.status)) LIKE $2`;
}
const result = await pool.query(
`SELECT l.id, l.elevator_id, l.elevator_name, l.serial_number, l.location, l.route_name, l.inspection_date,
l.next_inspection_date, l.inspection_interval_days, l.status, l.notes,
l.technician_name, l.created_by_user_id, l.created_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'id', lf.id,
'originalName', lf.original_name,
'size', lf.size,
'downloadPath', '/api/logs/' || l.id || '/files/' || lf.id
)
ORDER BY lf.id
) FILTER (WHERE lf.id IS NOT NULL),
'[]'::json
) AS files
FROM logs l
LEFT JOIN log_files lf ON lf.log_id = l.id
${whereClause}
GROUP BY l.id
ORDER BY ${buildLogOrder(sortBy)}`,
params
);
res.json({ logs: result.rows.map(mapLogRow) });
});
app.post("/api/logs", authenticateRequest, upload.array("pdfs", MAX_FILES), async (req, res) => {
const uploadedFiles = req.files || [];
const client = await pool.connect();
try {
const payload = normalizeLogPayload(req.body, req.user);
const validationMessage = validateLogPayload(payload);
if (validationMessage) {
removeUploadedFiles(uploadedFiles);
return res.status(400).json({ message: validationMessage });
}
await client.query("BEGIN");
let elevatorRow = null;
if (payload.elevatorId) {
elevatorRow = await findElevatorById(client, payload.elevatorId, req.user.routeName);
if (!elevatorRow) {
removeUploadedFiles(uploadedFiles);
return res.status(404).json({ message: "A kiválasztott eszköz nem található." });
}
const updatedElevator = await client.query(
`UPDATE elevators
SET last_inspection_date = $1,
next_inspection_date = $2,
inspection_interval_days = $3,
last_status = $4,
updated_at = NOW()
WHERE id = $5
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at`,
[
payload.inspectionDate,
payload.nextInspectionDate,
payload.inspectionIntervalDays,
payload.status,
payload.elevatorId
]
);
elevatorRow = updatedElevator.rows[0];
} else {
const upsertedElevator = await client.query(
`INSERT INTO elevators (
name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (serial_number)
DO UPDATE SET
name = EXCLUDED.name,
location = EXCLUDED.location,
route_name = EXCLUDED.route_name,
last_inspection_date = EXCLUDED.last_inspection_date,
next_inspection_date = EXCLUDED.next_inspection_date,
inspection_interval_days = EXCLUDED.inspection_interval_days,
last_status = EXCLUDED.last_status,
updated_at = NOW()
RETURNING id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at`,
[
payload.elevatorName,
payload.serialNumber,
payload.location,
payload.routeName,
payload.inspectionDate,
payload.nextInspectionDate,
payload.inspectionIntervalDays,
payload.status
]
);
elevatorRow = upsertedElevator.rows[0];
}
const logInsert = await client.query(
`INSERT INTO logs (
elevator_id, elevator_name, serial_number, location, route_name, inspection_date,
next_inspection_date, inspection_interval_days, status, notes,
technician_name, created_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
elevatorRow.id,
elevatorRow.name,
elevatorRow.serial_number,
elevatorRow.location,
elevatorRow.route_name,
payload.inspectionDate,
payload.nextInspectionDate,
payload.inspectionIntervalDays,
payload.status,
payload.notes,
req.user.fullName,
req.user.id
]
);
const logId = logInsert.rows[0].id;
for (const file of uploadedFiles) {
const originalName = normalizeUploadedFilename(file.originalname);
await client.query(
`INSERT INTO log_files (log_id, original_name, stored_name, size, mime_type)
VALUES ($1, $2, $3, $4, $5)`,
[logId, originalName, file.filename, file.size, file.mimetype]
);
}
await client.query("COMMIT");
res.status(201).json({
message: "Az ellenőrzési bejegyzés sikeresen mentve lett.",
log: await fetchLogById(logId)
});
} catch (_error) {
await client.query("ROLLBACK");
removeUploadedFiles(uploadedFiles);
res.status(500).json({ message: "Hiba történt a bejegyzés mentése közben." });
} finally {
client.release();
}
});
app.get("/api/logs/:logId/files/:fileId", authenticateRequest, async (req, res) => {
const logId = Number(req.params.logId);
const fileId = Number(req.params.fileId);
const result = await pool.query(
`SELECT stored_name, original_name
FROM log_files
WHERE id = $1 AND log_id = $2`,
[fileId, logId]
);
if (!result.rowCount) {
return res.status(404).json({ message: "A keresett fájl nem található." });
}
const file = result.rows[0];
const absolutePath = path.join(UPLOAD_DIR, file.stored_name);
if (!fs.existsSync(absolutePath)) {
return res.status(404).json({ message: "A fájl már nem érhető el." });
}
return res.download(absolutePath, normalizeUploadedFilename(file.original_name));
});
app.use((error, req, res, _next) => {
removeUploadedFiles(req.files || []);
const isMulterError = error instanceof multer.MulterError;
const message = isMulterError
? "A PDF feltöltés sikertelen volt. Ellenőrizd a fájlméretet és a darabszámot."
: error.message || "Váratlan szerverhiba történt.";
res.status(400).json({ message });
});
startServer();
async function startServer() {
try {
await initializeDatabase();
await migrateLegacyJsonIfNeeded();
app.listen(PORT, () => {
console.log(`A liftellenőrzési kezelő fut a(z) http://localhost:${PORT} címen.`);
});
} catch (error) {
console.error("Nem sikerült elindítani a szervert:", error.message);
process.exit(1);
}
}
function ensureDirectories() {
[DATA_DIR, UPLOAD_DIR, PUBLIC_DIR].forEach((directory) => {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
});
}
async function initializeDatabase() {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
route_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS elevators (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
serial_number TEXT NOT NULL UNIQUE,
location TEXT NOT NULL,
route_name TEXT NOT NULL,
last_inspection_date DATE,
next_inspection_date DATE,
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
last_status TEXT NOT NULL DEFAULT 'rendben',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL,
elevator_name TEXT NOT NULL,
serial_number TEXT NOT NULL,
location TEXT NOT NULL,
route_name TEXT NOT NULL,
inspection_date DATE NOT NULL,
next_inspection_date DATE NOT NULL,
inspection_interval_days INTEGER NOT NULL DEFAULT 30,
status TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
technician_name TEXT NOT NULL,
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS log_files (
id SERIAL PRIMARY KEY,
log_id INTEGER NOT NULL REFERENCES logs(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type TEXT NOT NULL
);
`);
await pool.query(`
ALTER TABLE logs
ADD COLUMN IF NOT EXISTS elevator_id INTEGER REFERENCES elevators(id) ON DELETE SET NULL;
`);
}
async function migrateLegacyJsonIfNeeded() {
if (!fs.existsSync(LEGACY_DATA_FILE)) {
return;
}
const userCount = await pool.query("SELECT COUNT(*)::int AS count FROM users");
if (userCount.rows[0].count > 0) {
return;
}
const legacy = JSON.parse(fs.readFileSync(LEGACY_DATA_FILE, "utf-8"));
const client = await pool.connect();
const userIdMap = new Map();
try {
await client.query("BEGIN");
for (const user of legacy.users || []) {
const result = await client.query(
`INSERT INTO users (username, password_hash, full_name, route_name, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[user.username, user.passwordHash, user.fullName, user.routeName, user.createdAt || new Date().toISOString()]
);
userIdMap.set(user.id, result.rows[0].id);
}
for (const elevator of legacy.elevators || []) {
await client.query(
`INSERT INTO elevators (
name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
elevator.name,
elevator.serialNumber,
elevator.location,
elevator.routeName,
elevator.lastInspectionDate || null,
elevator.nextInspectionDate || null,
elevator.inspectionIntervalDays || 30,
elevator.lastStatus || "rendben",
elevator.updatedAt || new Date().toISOString()
]
);
}
for (const log of legacy.logs || []) {
const logResult = await client.query(
`INSERT INTO logs (
elevator_name, serial_number, location, route_name, inspection_date,
next_inspection_date, inspection_interval_days, status, notes,
technician_name, created_by_user_id, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
log.elevatorName,
log.serialNumber,
log.location,
log.routeName,
log.inspectionDate,
log.nextInspectionDate,
log.inspectionIntervalDays || 30,
log.status || "rendben",
log.notes || "",
log.technicianName,
userIdMap.get(log.createdByUserId) || null,
log.createdAt || new Date().toISOString()
]
);
for (const file of log.files || []) {
await client.query(
`INSERT INTO log_files (log_id, original_name, stored_name, size, mime_type)
VALUES ($1, $2, $3, $4, $5)`,
[logResult.rows[0].id, file.originalName, file.storedName, file.size || 0, file.mimeType || "application/pdf"]
);
}
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
function createToken(user) {
return jwt.sign(user, JWT_SECRET, { expiresIn: "8h" });
}
function setAuthCookie(res, token) {
res.setHeader("Set-Cookie", serializeCookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: 8 * 60 * 60
}));
}
function clearAuthCookie(res) {
res.setHeader("Set-Cookie", serializeCookie(COOKIE_NAME, "", {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: 0
}));
}
function serializeCookie(name, value, options) {
const parts = [`${name}=${encodeURIComponent(value)}`];
parts.push(`Path=${options.path || "/"}`);
parts.push(`Max-Age=${options.maxAge || 0}`);
parts.push("HttpOnly");
parts.push(`SameSite=${options.sameSite || "Lax"}`);
return parts.join("; ");
}
function parseCookies(req) {
const rawCookie = req.headers.cookie || "";
return rawCookie.split(";").reduce((cookies, entry) => {
const [name, ...rest] = entry.trim().split("=");
if (!name) {
return cookies;
}
cookies[name] = decodeURIComponent(rest.join("="));
return cookies;
}, {});
}
function getCookieToken(req) {
return parseCookies(req)[COOKIE_NAME] || "";
}
function getTokenFromRequest(req) {
const headerToken = req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: "";
const queryToken = req.query.token?.trim() || "";
const cookieToken = getCookieToken(req);
return headerToken || cookieToken || queryToken;
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (_error) {
return null;
}
}
function requireGuestPage(req, res, next) {
const token = getCookieToken(req);
return token && verifyToken(token) ? res.redirect("/alkalmazas") : next();
}
function requirePageAuth(req, res, next) {
const token = getCookieToken(req);
const decoded = token ? verifyToken(token) : null;
if (!decoded) {
return res.redirect("/bejelentkezes");
}
req.user = decoded;
next();
}
function authenticateRequest(req, res, next) {
const token = getTokenFromRequest(req);
const decoded = token ? verifyToken(token) : null;
if (!decoded) {
return res.status(401).json({ message: "Bejelentkezés szükséges." });
}
req.user = decoded;
next();
}
function normalizeElevatorPayload(body, user) {
const inspectionIntervalDays = Number(body.inspectionIntervalDays || 30);
return {
name: body.name?.trim(),
serialNumber: body.serialNumber?.trim().toUpperCase(),
location: body.location?.trim(),
routeName: user.routeName,
lastInspectionDate: body.lastInspectionDate?.trim() || null,
nextInspectionDate: body.nextInspectionDate?.trim() || null,
inspectionIntervalDays: Number.isFinite(inspectionIntervalDays) ? inspectionIntervalDays : 30,
status: body.status?.trim() || "rendben"
};
}
function validateElevatorPayload(payload) {
if (!payload.name || !payload.serialNumber || !payload.location) {
return "Az eszköz neve, gyári száma és helyszíne kötelező.";
}
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
}
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
}
return "";
}
function normalizeLogPayload(body, user) {
const inspectionIntervalDays = Number(body.inspectionIntervalDays || 30);
return {
elevatorId: Number(body.elevatorId || 0) || null,
elevatorName: body.elevatorName?.trim(),
serialNumber: body.serialNumber?.trim().toUpperCase(),
location: body.location?.trim(),
routeName: body.routeName?.trim() || user.routeName,
inspectionDate: body.inspectionDate?.trim(),
nextInspectionDate: body.nextInspectionDate?.trim(),
inspectionIntervalDays: Number.isFinite(inspectionIntervalDays) ? inspectionIntervalDays : 30,
status: body.status?.trim() || "rendben",
notes: body.notes?.trim() || ""
};
}
function validateLogPayload(payload) {
if (!payload.elevatorId && (!payload.elevatorName || !payload.serialNumber || !payload.location)) {
return "A lift neve, gyári száma és helyszíne kötelező.";
}
if (!payload.inspectionDate || !payload.nextInspectionDate) {
return "Az ellenőrzés és a következő ellenőrzés dátuma kötelező.";
}
if (!Number.isInteger(payload.inspectionIntervalDays) || payload.inspectionIntervalDays < 1) {
return "Az ellenőrzési ciklusnak legalább 1 napnak kell lennie.";
}
return "";
}
function removeUploadedFiles(files) {
files.forEach((file) => {
const absolutePath = path.join(UPLOAD_DIR, file.filename);
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
});
}
function buildLogOrder(sortBy) {
const orderMap = {
inspectionDateAsc: "l.inspection_date ASC, l.id DESC",
inspectionDateDesc: "l.inspection_date DESC, l.id DESC",
nextInspectionAsc: "l.next_inspection_date ASC, l.id DESC",
nextInspectionDesc: "l.next_inspection_date DESC, l.id DESC",
elevatorNameAsc: "l.elevator_name ASC, l.id DESC",
statusAsc: "l.status ASC, l.id DESC"
};
return orderMap[sortBy] || orderMap.inspectionDateDesc;
}
async function fetchLogById(logId) {
const result = await pool.query(
`SELECT l.id, l.elevator_id, l.elevator_name, l.serial_number, l.location, l.route_name, l.inspection_date,
l.next_inspection_date, l.inspection_interval_days, l.status, l.notes,
l.technician_name, l.created_by_user_id, l.created_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'id', lf.id,
'originalName', lf.original_name,
'size', lf.size,
'downloadPath', '/api/logs/' || l.id || '/files/' || lf.id
)
ORDER BY lf.id
) FILTER (WHERE lf.id IS NOT NULL),
'[]'::json
) AS files
FROM logs l
LEFT JOIN log_files lf ON lf.log_id = l.id
WHERE l.id = $1
GROUP BY l.id`,
[logId]
);
return result.rowCount ? mapLogRow(result.rows[0]) : null;
}
async function findElevatorById(executor, elevatorId, routeName) {
const result = await executor.query(
`SELECT id, name, serial_number, location, route_name, last_inspection_date,
next_inspection_date, inspection_interval_days, last_status, updated_at
FROM elevators
WHERE id = $1 AND route_name = $2`,
[elevatorId, routeName]
);
return result.rowCount ? result.rows[0] : null;
}
function mapUserRow(row) {
return {
id: row.id,
username: row.username,
fullName: row.full_name,
routeName: row.route_name
};
}
function mapElevatorRow(row) {
return {
id: row.id,
name: row.name,
serialNumber: row.serial_number,
location: row.location,
routeName: row.route_name,
lastInspectionDate: row.last_inspection_date,
nextInspectionDate: row.next_inspection_date,
inspectionIntervalDays: row.inspection_interval_days,
lastStatus: row.last_status,
updatedAt: row.updated_at
};
}
function mapLogRow(row) {
return {
id: row.id,
elevatorId: row.elevator_id,
elevatorName: row.elevator_name,
serialNumber: row.serial_number,
location: row.location,
routeName: row.route_name,
inspectionDate: row.inspection_date,
nextInspectionDate: row.next_inspection_date,
inspectionIntervalDays: row.inspection_interval_days,
status: row.status,
notes: row.notes,
technicianName: row.technician_name,
createdByUserId: row.created_by_user_id,
createdAt: row.created_at,
files: Array.isArray(row.files)
? row.files.map((file) => ({
...file,
originalName: normalizeUploadedFilename(file.originalName)
}))
: []
};
}
function normalizeUploadedFilename(value) {
if (!value || !containsLikelyFilenameMojibake(value)) {
return value;
}
const decoded = Buffer.from(value, "latin1").toString("utf8").normalize("NFC");
return containsReplacementCharacter(decoded) ? value : decoded;
}
function containsLikelyFilenameMojibake(value) {
return /[\u00C2-\u00C5\u00CC-\u00CF\u00D0-\u00D6\u00D8-\u00DE\u0080-\u009F]/.test(value);
return /[ÃÅÂ]/.test(value);
}
function containsReplacementCharacter(value) {
return value.includes("\uFFFD");
}