first commit
This commit is contained in:
commit
b0ee86c924
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
uploads
|
||||||
3
.env.example
Normal file
3
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
uploads/
|
||||||
|
data/store.json
|
||||||
|
.env
|
||||||
239
README.md
Normal file
239
README.md
Normal 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
54
compose.yaml
Normal 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
67
nginx/default.conf
Normal 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
1785
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
189
public/app.html
Normal 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
543
public/app.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
42
public/auth.js
Normal file
42
public/auth.js
Normal 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
116
public/index.html
Normal 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
51
public/login.html
Normal 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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 KiB |
60
public/logs.html
Normal file
60
public/logs.html
Normal 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
261
public/logs.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
57
public/register.html
Normal file
57
public/register.html
Normal 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
788
public/style.css
Normal 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
419
scripts/seed.js
Normal 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
944
server.js
Normal 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");
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user