first commit

This commit is contained in:
2026-05-22 23:05:37 +02:00
commit b0ee86c924
20 changed files with 5655 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alkalmazás | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-5" />
</head>
<body>
<div id="pageShell">
<main id="appShell">
<div id="topBar" class="panel app-topbar">
<div class="topbar-heading">
<img class="topbar-logo" src="/logo.png" alt="" aria-hidden="true" />
<div>
<h1 id="welcomeText" class="page-title">Kezelőfelület</h1>
<p id="routeText" class="page-text"></p>
</div>
</div>
<div class="panel-actions">
<a class="ghost-button" href="/naplo">Napló megnyitása</a>
<button id="logoutButton" class="ghost-button" type="button">Kijelentkezés</button>
</div>
</div>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Határidők</h2>
<p>A bejelentkezett technikus útvonalához tartozó, a következő 7 napban esedékes ellenőrzések.</p>
</div>
</div>
<div id="dashboardCards" class="dashboard-grid"></div>
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Eszközök</h2>
<p>Külön kezelt eszközlista, amelynek az állapota új naplóbejegyzésekkel frissül.</p>
</div>
<div class="panel-actions">
<button id="openDeviceModalButton" class="secondary-button" type="button">Új eszköz</button>
<button id="openLogModalButton" class="primary-button" type="button">Új napló</button>
</div>
</div>
<div id="deviceList" class="entries-grid"></div>
</section>
</main>
</div>
<div id="deviceModal" class="modal-backdrop hidden">
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="deviceModalTitle">
<div class="modal-head">
<div class="section-heading">
<h2 id="deviceModalTitle">Új eszköz</h2>
<p id="deviceModalSubtitle">Új lift vagy berendezés rögzítése a technikusi útvonalhoz.</p>
</div>
<button id="closeDeviceModalButton" class="ghost-button modal-close" type="button">Bezárás</button>
</div>
<form id="deviceForm">
<input id="deviceEditId" name="elevatorId" type="hidden" />
<div class="form-grid">
<div class="field-wrap">
<label for="deviceName">Eszköz neve</label>
<input id="deviceName" name="name" type="text" required />
</div>
<div class="field-wrap">
<label for="deviceSerialNumber">Gyári szám</label>
<input id="deviceSerialNumber" name="serialNumber" type="text" required />
</div>
<div class="field-wrap field-wide">
<label for="deviceLocation">Helyszín</label>
<input id="deviceLocation" name="location" type="text" required />
</div>
<div class="field-wrap">
<label for="deviceRouteName">Technikusi útvonal</label>
<input id="deviceRouteName" class="readonly-input" name="routeName" type="text" readonly />
</div>
<div class="field-wrap">
<label for="deviceStatus">Aktuális állapot</label>
<select id="deviceStatus" name="status">
<option value="rendben">Rendben</option>
<option value="figyelmeztetes">Figyelmeztetés</option>
<option value="lejart">Lejárt</option>
</select>
</div>
<div class="field-wrap">
<label for="deviceInspectionIntervalDays">Ellenőrzési ciklus napokban</label>
<input id="deviceInspectionIntervalDays" name="inspectionIntervalDays" type="number" min="1" value="30" required />
</div>
<div class="field-wrap">
<label for="deviceNextInspectionDate">Következő ellenőrzés</label>
<input id="deviceNextInspectionDate" name="nextInspectionDate" type="date" />
</div>
</div>
<div class="form-actions">
<div class="inline-actions">
<button id="deviceSubmitButton" class="primary-button" type="submit">Eszköz mentése</button>
<button id="cancelDeviceModalButton" class="ghost-button" type="button">Mégse</button>
</div>
<p id="deviceMessage" class="feedback-text"></p>
</div>
</form>
</div>
</div>
<div id="logModal" class="modal-backdrop hidden">
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="logModalTitle">
<div class="modal-head">
<div class="section-heading">
<h2 id="logModalTitle">Új naplóbejegyzés</h2>
<p>Egy meglévő eszköz állapotának frissítése új ellenőrzési naplóval.</p>
</div>
<button id="closeLogModalButton" class="ghost-button modal-close" type="button">Bezárás</button>
</div>
<form id="logForm">
<div class="form-grid">
<div class="field-wrap field-wide">
<label for="logElevatorId">Eszköz kiválasztása</label>
<select id="logElevatorId" name="elevatorId" required></select>
</div>
<div class="field-wrap field-wide">
<div id="logElevatorSummary" class="summary-card">
Válassz ki egy eszközt a naplóbejegyzéshez.
</div>
</div>
<div class="field-wrap">
<label for="logStatus">Állapot</label>
<select id="logStatus" name="status">
<option value="rendben">Rendben</option>
<option value="figyelmeztetes">Figyelmeztetés</option>
<option value="lejart">Lejárt</option>
</select>
</div>
<div class="field-wrap">
<label for="logInspectionDate">Ellenőrzés dátuma</label>
<input id="logInspectionDate" name="inspectionDate" type="date" required />
</div>
<div class="field-wrap">
<label for="logInspectionIntervalDays">Ellenőrzési ciklus napokban</label>
<input id="logInspectionIntervalDays" name="inspectionIntervalDays" type="number" min="1" value="30" required />
</div>
<div class="field-wrap">
<label for="logNextInspectionDate">Következő ellenőrzés</label>
<input id="logNextInspectionDate" name="nextInspectionDate" type="date" required />
</div>
<div class="field-wrap field-wide">
<label for="logNotes">Megjegyzés</label>
<textarea id="logNotes" name="notes" rows="4" placeholder="Rövid állapotleírás vagy teendő"></textarea>
</div>
<div class="field-wrap field-wide">
<label for="logPdfs">PDF csatolmányok</label>
<input id="logPdfs" name="pdfs" type="file" accept="application/pdf,.pdf" multiple />
</div>
</div>
<div class="form-actions">
<div class="inline-actions">
<button id="logSubmitButton" class="primary-button" type="submit">Bejegyzés mentése</button>
<button id="openDeviceFromLogButton" class="secondary-button" type="button">Új eszköz létrehozása</button>
<button id="cancelLogModalButton" class="ghost-button" type="button">Mégse</button>
</div>
<p id="logMessage" class="feedback-text"></p>
</div>
</form>
</div>
</div>
<script src="/app.js?v=20260522-5"></script>
</body>
</html>
+543
View File
@@ -0,0 +1,543 @@
const state = {
user: null,
devices: [],
dashboardElevators: [],
countdownIntervalId: null,
activeModal: null,
selectedLogElevatorId: null
};
const elements = {
welcomeText: document.getElementById("welcomeText"),
routeText: document.getElementById("routeText"),
logoutButton: document.getElementById("logoutButton"),
dashboardCards: document.getElementById("dashboardCards"),
deviceList: document.getElementById("deviceList"),
openDeviceModalButton: document.getElementById("openDeviceModalButton"),
openLogModalButton: document.getElementById("openLogModalButton"),
deviceModal: document.getElementById("deviceModal"),
logModal: document.getElementById("logModal"),
deviceForm: document.getElementById("deviceForm"),
deviceEditId: document.getElementById("deviceEditId"),
deviceModalTitle: document.getElementById("deviceModalTitle"),
deviceModalSubtitle: document.getElementById("deviceModalSubtitle"),
deviceRouteName: document.getElementById("deviceRouteName"),
deviceName: document.getElementById("deviceName"),
deviceSerialNumber: document.getElementById("deviceSerialNumber"),
deviceLocation: document.getElementById("deviceLocation"),
deviceStatus: document.getElementById("deviceStatus"),
deviceInspectionIntervalDays: document.getElementById("deviceInspectionIntervalDays"),
deviceNextInspectionDate: document.getElementById("deviceNextInspectionDate"),
deviceMessage: document.getElementById("deviceMessage"),
closeDeviceModalButton: document.getElementById("closeDeviceModalButton"),
cancelDeviceModalButton: document.getElementById("cancelDeviceModalButton"),
logForm: document.getElementById("logForm"),
logElevatorId: document.getElementById("logElevatorId"),
logElevatorSummary: document.getElementById("logElevatorSummary"),
logStatus: document.getElementById("logStatus"),
logInspectionDate: document.getElementById("logInspectionDate"),
logInspectionIntervalDays: document.getElementById("logInspectionIntervalDays"),
logNextInspectionDate: document.getElementById("logNextInspectionDate"),
logMessage: document.getElementById("logMessage"),
closeLogModalButton: document.getElementById("closeLogModalButton"),
cancelLogModalButton: document.getElementById("cancelLogModalButton"),
openDeviceFromLogButton: document.getElementById("openDeviceFromLogButton")
};
bindEvents();
bootPage();
function bindEvents() {
elements.logoutButton.addEventListener("click", logout);
elements.openDeviceModalButton.addEventListener("click", () => openDeviceModal());
elements.openLogModalButton.addEventListener("click", () => openLogModal());
elements.deviceForm.addEventListener("submit", handleDeviceSave);
elements.logForm.addEventListener("submit", handleLogSave);
elements.closeDeviceModalButton.addEventListener("click", closeActiveModal);
elements.cancelDeviceModalButton.addEventListener("click", closeActiveModal);
elements.closeLogModalButton.addEventListener("click", closeActiveModal);
elements.cancelLogModalButton.addEventListener("click", closeActiveModal);
elements.openDeviceFromLogButton.addEventListener("click", handleOpenDeviceFromLog);
elements.logElevatorId.addEventListener("change", handleLogElevatorChange);
elements.logInspectionDate.addEventListener("change", syncLogNextInspectionDate);
elements.logInspectionIntervalDays.addEventListener("input", syncLogNextInspectionDate);
elements.deviceList.addEventListener("click", handleDeviceListClick);
elements.deviceModal.addEventListener("click", handleModalBackdropClick);
elements.logModal.addEventListener("click", handleModalBackdropClick);
window.addEventListener("keydown", handleWindowKeydown);
window.addEventListener("pageshow", handlePageShow);
}
async function bootPage() {
setToday(elements.logInspectionDate);
syncLogNextInspectionDate();
renderDevices();
renderDashboard();
try {
const response = await apiFetch("/api/me");
state.user = response.user;
fillUserHeader();
elements.deviceRouteName.value = state.user?.routeName || "";
await Promise.allSettled([loadDashboard(), loadDevices()]);
} catch (error) {
if (error.status === 401) {
window.location.href = "/bejelentkezes";
return;
}
elements.welcomeText.textContent = "Betöltési hiba";
elements.routeText.textContent = "A munkamenet ellenőrzése nem sikerült. Frissítsd az oldalt vagy jelentkezz be újra.";
setFeedback(elements.logMessage, error.message, "error");
}
}
function fillUserHeader() {
const name = state.user?.fullName || "Felhasználó";
const route = state.user?.routeName || "Nincs megadva";
elements.welcomeText.textContent = "Kezelőfelület";
elements.routeText.textContent = `${name} · Technikusi útvonal: ${route}`;
}
async function loadDashboard() {
try {
const response = await apiFetch("/api/dashboard");
state.dashboardElevators = response.elevators || [];
renderDashboard();
} catch (error) {
state.dashboardElevators = [];
renderDashboard();
setFeedback(elements.logMessage, error.message, "error");
}
}
async function loadDevices() {
try {
const response = await apiFetch("/api/elevators");
state.devices = response.elevators || [];
renderDevices();
renderLogElevatorOptions(state.selectedLogElevatorId);
} catch (error) {
state.devices = [];
renderDevices();
renderLogElevatorOptions(null);
setFeedback(elements.deviceMessage, error.message, "error");
}
}
function renderDashboard() {
if (!state.dashboardElevators.length) {
elements.dashboardCards.innerHTML = `
<article class="dashboard-card empty-card">
<h3>Nincs közelgő határidő</h3>
<p>A következő 7 napban esedékes ellenőrzések itt jelennek meg.</p>
</article>
`;
clearCountdownTimer();
return;
}
elements.dashboardCards.innerHTML = state.dashboardElevators
.map((elevator) => {
const countdown = calculateCountdown(elevator.nextInspectionDate);
const statusClass = countdown.expired ? "danger-chip" : countdown.warning ? "warning-chip" : "success-chip";
return `
<article class="dashboard-card">
<div class="card-topline">
<span class="status-chip ${statusClass}">${countdown.label}</span>
<span class="serial-chip">${escapeHtml(elevator.serialNumber)}</span>
</div>
<h3>${escapeHtml(elevator.name)}</h3>
<p>${escapeHtml(elevator.location)}</p>
<p>Legutóbbi állapot: ${humanizeStatus(elevator.lastStatus)}</p>
<div class="countdown-box" data-next-date="${elevator.nextInspectionDate}">
${countdown.text}
</div>
<p class="muted-text">Következő ellenőrzés: ${formatDate(elevator.nextInspectionDate)}</p>
</article>
`;
})
.join("");
clearCountdownTimer();
state.countdownIntervalId = window.setInterval(updateCountdownBoxes, 1000);
}
function renderDevices() {
if (!state.devices.length) {
elements.deviceList.innerHTML = `
<article class="entry-card empty-card">
<h3>Még nincs rögzített eszköz</h3>
<p>Hozz létre egy új eszközt, majd adj hozzá naplóbejegyzéseket az állapot frissítéséhez.</p>
</article>
`;
elements.openLogModalButton.disabled = true;
return;
}
elements.openLogModalButton.disabled = false;
elements.deviceList.innerHTML = state.devices
.map((device) => `
<article class="entry-card device-card">
<div class="card-topline">
<span class="status-chip ${statusClassName(device.lastStatus)}">${humanizeStatus(device.lastStatus)}</span>
<span class="serial-chip">${escapeHtml(device.serialNumber)}</span>
</div>
<h3>${escapeHtml(device.name)}</h3>
<p><strong>Helyszín:</strong> ${escapeHtml(device.location)}</p>
<p><strong>Útvonal:</strong> ${escapeHtml(device.routeName)}</p>
<p><strong>Utolsó ellenőrzés:</strong> ${formatDate(device.lastInspectionDate)}</p>
<p><strong>Következő ellenőrzés:</strong> ${formatDate(device.nextInspectionDate)}</p>
<p><strong>Ciklus:</strong> ${escapeHtml(device.inspectionIntervalDays)} nap</p>
<div class="device-actions">
<button class="secondary-button" type="button" data-action="log" data-elevator-id="${device.id}">Új napló</button>
<button class="ghost-button" type="button" data-action="edit" data-elevator-id="${device.id}">Szerkesztés</button>
</div>
</article>
`)
.join("");
}
function renderLogElevatorOptions(preferredElevatorId) {
if (!state.devices.length) {
state.selectedLogElevatorId = null;
elements.logElevatorId.innerHTML = `<option value="">Előbb hozz létre egy eszközt</option>`;
elements.logElevatorSummary.textContent = "A naplózáshoz előbb egy eszközt kell létrehozni.";
return;
}
const fallbackId = preferredElevatorId || state.selectedLogElevatorId || state.devices[0].id;
const resolvedId = state.devices.some((device) => device.id === Number(fallbackId)) ? Number(fallbackId) : state.devices[0].id;
state.selectedLogElevatorId = resolvedId;
elements.logElevatorId.innerHTML = state.devices
.map(
(device) => `
<option value="${device.id}" ${device.id === resolvedId ? "selected" : ""}>
${escapeHtml(device.name)} · ${escapeHtml(device.serialNumber)}
</option>
`
)
.join("");
renderSelectedElevatorSummary(resolvedId);
}
function renderSelectedElevatorSummary(elevatorId) {
const device = state.devices.find((entry) => entry.id === Number(elevatorId));
if (!device) {
elements.logElevatorSummary.textContent = "Válassz ki egy eszközt a naplóbejegyzéshez.";
return;
}
elements.logElevatorSummary.innerHTML = `
<strong>${escapeHtml(device.name)}</strong><br />
Gyári szám: ${escapeHtml(device.serialNumber)}<br />
Helyszín: ${escapeHtml(device.location)}<br />
Utolsó állapot: ${humanizeStatus(device.lastStatus)}<br />
Következő ellenőrzés: ${formatDate(device.nextInspectionDate)}
`;
}
function updateCountdownBoxes() {
document.querySelectorAll(".countdown-box").forEach((box) => {
const countdown = calculateCountdown(box.dataset.nextDate);
box.textContent = countdown.text;
});
}
function clearCountdownTimer() {
if (state.countdownIntervalId) {
window.clearInterval(state.countdownIntervalId);
state.countdownIntervalId = null;
}
}
function handlePageShow() {
if (state.user) {
Promise.allSettled([loadDashboard(), loadDevices()]);
}
}
function handleDeviceListClick(event) {
const button = event.target.closest("button[data-action]");
if (!button) {
return;
}
const elevatorId = Number(button.dataset.elevatorId);
if (button.dataset.action === "log") {
openLogModal(elevatorId);
return;
}
if (button.dataset.action === "edit") {
openDeviceModal(elevatorId);
}
}
function handleLogElevatorChange() {
const elevatorId = Number(elements.logElevatorId.value);
state.selectedLogElevatorId = Number.isInteger(elevatorId) && elevatorId > 0 ? elevatorId : null;
renderSelectedElevatorSummary(state.selectedLogElevatorId);
const device = getSelectedElevator();
if (device) {
elements.logInspectionIntervalDays.value = device.inspectionIntervalDays || 30;
syncLogNextInspectionDate();
}
}
function handleModalBackdropClick(event) {
if (event.target === event.currentTarget) {
closeActiveModal();
}
}
function handleWindowKeydown(event) {
if (event.key === "Escape" && state.activeModal) {
closeActiveModal();
}
}
function handleOpenDeviceFromLog() {
closeActiveModal();
openDeviceModal(null, true);
}
async function handleDeviceSave(event) {
event.preventDefault();
const elevatorId = Number(elements.deviceEditId.value || 0);
const payload = {
name: elements.deviceName.value,
serialNumber: elements.deviceSerialNumber.value,
location: elements.deviceLocation.value,
routeName: elements.deviceRouteName.value,
status: elements.deviceStatus.value,
inspectionIntervalDays: elements.deviceInspectionIntervalDays.value,
nextInspectionDate: elements.deviceNextInspectionDate.value || "",
lastInspectionDate: elements.deviceForm.dataset.lastInspectionDate || ""
};
try {
const response = await apiFetch(elevatorId ? `/api/elevators/${elevatorId}` : "/api/elevators", {
method: elevatorId ? "PUT" : "POST",
body: JSON.stringify(payload)
});
setFeedback(elements.deviceMessage, response.message, "success");
await Promise.allSettled([loadDevices(), loadDashboard()]);
closeActiveModal();
if (elements.deviceForm.dataset.returnToLog === "true" && response.elevator?.id) {
openLogModal(response.elevator.id);
}
} catch (error) {
setFeedback(elements.deviceMessage, error.message, "error");
}
}
async function handleLogSave(event) {
event.preventDefault();
const formData = new FormData(elements.logForm);
try {
const response = await apiFetch("/api/logs", {
method: "POST",
body: formData,
isFormData: true
});
setFeedback(elements.logMessage, response.message, "success");
await Promise.allSettled([loadDashboard(), loadDevices()]);
closeActiveModal();
} catch (error) {
setFeedback(elements.logMessage, error.message, "error");
}
}
async function logout() {
try {
const response = await apiFetch("/api/logout", { method: "POST" });
window.location.href = response.redirectPath;
} catch (_error) {
window.location.href = "/bejelentkezes";
}
}
function openDeviceModal(elevatorId = null, returnToLog = false) {
const device = state.devices.find((entry) => entry.id === Number(elevatorId));
elements.deviceForm.reset();
elements.deviceForm.dataset.returnToLog = returnToLog ? "true" : "false";
elements.deviceForm.dataset.lastInspectionDate = normalizeDateInput(device?.lastInspectionDate);
elements.deviceEditId.value = device?.id || "";
elements.deviceRouteName.value = state.user?.routeName || "";
elements.deviceInspectionIntervalDays.value = device?.inspectionIntervalDays || 30;
elements.deviceStatus.value = device?.lastStatus || "rendben";
elements.deviceNextInspectionDate.value = normalizeDateInput(device?.nextInspectionDate);
elements.deviceName.value = device?.name || "";
elements.deviceSerialNumber.value = device?.serialNumber || "";
elements.deviceLocation.value = device?.location || "";
elements.deviceModalTitle.textContent = device ? "Eszköz szerkesztése" : "Új eszköz";
elements.deviceModalSubtitle.textContent = device
? "Az eszköz alapadatainak frissítése. Az állapotot naplóbejegyzéssel is módosíthatod."
: "Új lift vagy berendezés rögzítése a technikusi útvonalhoz.";
setFeedback(elements.deviceMessage, "", "");
showModal(elements.deviceModal);
}
function openLogModal(elevatorId = null) {
if (!state.devices.length) {
openDeviceModal();
setFeedback(elements.deviceMessage, "A naplózáshoz előbb létre kell hoznod egy eszközt.", "error");
return;
}
elements.logForm.reset();
setFeedback(elements.logMessage, "", "");
setToday(elements.logInspectionDate);
elements.logStatus.value = "rendben";
renderLogElevatorOptions(elevatorId);
elements.logElevatorId.value = String(state.selectedLogElevatorId || "");
handleLogElevatorChange();
showModal(elements.logModal);
}
function showModal(modalElement) {
closeActiveModal();
state.activeModal = modalElement;
modalElement.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function closeActiveModal() {
if (!state.activeModal) {
return;
}
state.activeModal.classList.add("hidden");
state.activeModal = null;
document.body.classList.remove("modal-open");
}
function getSelectedElevator() {
return state.devices.find((device) => device.id === Number(state.selectedLogElevatorId)) || null;
}
async function apiFetch(url, options = {}) {
const headers = options.isFormData ? {} : { "Content-Type": "application/json" };
const response = await fetch(url, {
method: options.method || "GET",
credentials: "include",
headers,
body: options.body
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json()
: { message: "A szerver nem JSON választ adott vissza." };
if (!response.ok) {
const error = new Error(data.message || "A kérés nem sikerült.");
error.status = response.status;
throw error;
}
return data;
}
function syncLogNextInspectionDate() {
const inspectionDate = elements.logInspectionDate.value;
const intervalDays = Number(elements.logInspectionIntervalDays.value || 30);
if (!inspectionDate) {
return;
}
const baseDate = new Date(`${inspectionDate}T00:00:00`);
baseDate.setDate(baseDate.getDate() + intervalDays);
elements.logNextInspectionDate.value = baseDate.toISOString().slice(0, 10);
}
function setToday(element) {
element.value = new Date().toISOString().slice(0, 10);
}
function calculateCountdown(nextInspectionDate) {
const normalizedDate = normalizeDateInput(nextInspectionDate);
if (!normalizedDate) {
return {
expired: false,
warning: false,
label: "Nincs dátum",
text: "Nincs megadott határidő"
};
}
const targetTime = new Date(`${normalizedDate}T23:59:59`).getTime();
const diff = targetTime - Date.now();
const expired = diff < 0;
const absoluteDiff = Math.abs(diff);
const totalHours = Math.floor(absoluteDiff / (1000 * 60 * 60));
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
const warning = !expired && days <= 7;
const label = expired ? "Lejárt" : warning ? "Hamarosan esedékes" : "Rendben";
const prefix = expired ? "Túllépés" : "Hátralévő idő";
return {
expired,
warning,
label,
text: `${prefix}: ${days} nap ${hours} óra`
};
}
function humanizeStatus(status) {
return status === "rendben" ? "Rendben" : status === "figyelmeztetes" ? "Figyelmeztetés" : "Lejárt";
}
function statusClassName(status) {
return status === "rendben" ? "success-chip" : status === "figyelmeztetes" ? "warning-chip" : "danger-chip";
}
function formatDate(value) {
const normalizedDate = normalizeDateInput(value);
return normalizedDate ? new Date(`${normalizedDate}T00:00:00`).toLocaleDateString("hu-HU") : "Nincs dátum";
}
function normalizeDateInput(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return value.includes("T") ? value.slice(0, 10) : value;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? "" : parsedDate.toISOString().slice(0, 10);
}
function setFeedback(element, message, type) {
element.textContent = message;
element.className = `feedback-text ${type ? `feedback-${type}` : ""}`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
+42
View File
@@ -0,0 +1,42 @@
const form = document.getElementById("authForm");
const feedback = document.getElementById("authMessage");
const isRegisterPage = window.location.pathname === "/regisztracio";
form?.addEventListener("submit", handleSubmit);
async function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
const endpoint = isRegisterPage ? "/api/register" : "/api/login";
try {
const response = await fetch(endpoint, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "A kérés nem sikerült.");
}
setFeedback(data.message, "success");
window.setTimeout(() => {
window.location.href = data.redirectPath;
}, 500);
} catch (error) {
setFeedback(error.message, "error");
}
}
function setFeedback(message, type) {
feedback.textContent = message;
feedback.className = `feedback-text ${type ? `feedback-${type}` : ""}`;
}
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Csödényi Felvonó Felülvizsgáló | Biztonság és szakértelem</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel landing-header">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">Csödényi Felvonó Felülvizsgáló</p>
<p class="brand-subtitle">Időszakos vizsgálatok, biztonságtechnikai ellenőrzések és szakértői támogatás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link landing-login-link" href="/bejelentkezes">Technikusi belépés</a>
</nav>
</header>
<main id="mainLayout" class="landing-layout">
<section class="landing-hero panel">
<div class="landing-hero-copy">
<p class="landing-kicker">Felvonó-felülvizsgálat kompromisszumok nélkül</p>
<h1 id="mainTitle" class="landing-title">Biztonság és szakértelem a legfelsőbb szinteken</h1>
<p id="heroText" class="landing-lead">
A lift nem pusztán kényelmi eszköz, hanem a mindennapi biztonság és a zavartalan közlekedés alapja.
A Csödényi Felvonó Felülvizsgálónál azért dolgozunk, hogy az Ön épületében üzemelő felvonók
megbízhatóan, szabályosan és kiszámíthatóan működjenek.
</p>
<p class="landing-lead">
Több éves szakmai tapasztalattal, naprakész jogszabályi ismeretekkel és korszerű diagnosztikai
eszközökkel végezzük személy- és teherfelvonók időszakos vizsgálatát, biztonságtechnikai ellenőrzését
és üzembe helyezés előtti felülvizsgálatát.
</p>
</div>
<img class="landing-hero-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Miért minket válasszon?</h2>
<p>Megbízható partner társasházak, irodaházak és ipari létesítmények számára.</p>
</div>
</div>
<div class="landing-feature-grid">
<article class="landing-feature-card">
<h3>Maximális precizitás</h3>
<p>A biztonság területén nem ismerünk kompromisszumot: minden ellenőrzést részletesen, dokumentáltan végzünk el.</p>
</article>
<article class="landing-feature-card">
<h3>Gyors és rugalmas ügyintézés</h3>
<p>Az épület működéséhez igazodunk, hogy a vizsgálatok a lehető legkevesebb fennakadással járjanak.</p>
</article>
<article class="landing-feature-card">
<h3>Teljes körű dokumentáció</h3>
<p>Átlátható, hatósági előírásoknak megfelelő jegyzőkönyveket és jól követhető szakmai összefoglalókat adunk át.</p>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Miben állunk rendelkezésére?</h2>
<p>Röviden összefoglaltuk, hogyan tudjuk támogatni az üzemeltetőket és közös képviselőket.</p>
</div>
</div>
<div class="landing-service-list">
<article class="landing-service-item">
<h3>Törvényi előírás szerinti időszakos ellenőrzések</h3>
<p>Negyedéves, féléves és éves biztonságtechnikai felülvizsgálatok a jogszabályi megfelelés érdekében.</p>
</article>
<article class="landing-service-item">
<h3>Üzembe helyezés előtti vizsgálat</h3>
<p>Új vagy modernizált liftek hatósági átadásának szakértői előkészítése és támogatása.</p>
</article>
<article class="landing-service-item">
<h3>Kockázatértékelés és állapotfelmérés</h3>
<p>Részletes műszaki helyzetkép, javítási javaslatokkal és korszerűsítési irányokkal.</p>
</article>
<article class="landing-service-item">
<h3>Szakértői tanácsadás</h3>
<p>Segítség közös képviselőknek, üzemeltetőknek és cégvezetőknek a vonatkozó szabványok és követelmények értelmezésében.</p>
</article>
</div>
</section>
<section class="landing-cta panel">
<div class="section-heading">
<h2>Közeleg a kötelező felülvizsgálat?</h2>
<p>
A biztonságos liftüzemeltetés és a szabályozások betartása komoly felelősség. Ne hagyja az utolsó
pillanatra a vizsgálat megszervezését.
</p>
</div>
<p class="landing-cta-text">
A Csödényi Felvonó Felülvizsgáló gyorsan, pontosan és a hatályos előírások szigorú követésével dolgozik,
hogy a működés zavartalan, a dokumentáció pedig kifogástalan legyen.
</p>
<div class="landing-contact-row">
<span>Telefon: +36 30 123 4567</span>
<span>E-mail: [email protected]</span>
<span>Webalkalmazás: technikusok részére</span>
</div>
</section>
</main>
</div>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bejelentkezés | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">LiftEllenőr</p>
<p class="brand-subtitle">Technikusi hozzáférés és vizsgálati naplózás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link" href="/">Főoldal</a>
<a class="nav-link nav-link-active" href="/bejelentkezes">Bejelentkezés</a>
<a class="nav-link" href="/regisztracio">Regisztráció</a>
</nav>
</header>
<main id="mainLayout">
<section class="panel auth-panel">
<div class="section-heading">
<h1 class="page-title">Bejelentkezés</h1>
<p class="page-text">A rendszer használatához jelentkezz be a felhasználói adataiddal.</p>
</div>
<form id="authForm" class="auth-form">
<label for="username">Felhasználónév</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">Jelszó</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<button class="primary-button" type="submit">Bejelentkezés</button>
</form>
<p id="authMessage" class="feedback-text"></p>
<p class="auth-link-row">Nincs még hozzáférésed? <a href="/regisztracio">Regisztráció</a></p>
</section>
</main>
</div>
<script src="/auth.js?v=20260522-1"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Napló | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-5" />
</head>
<body>
<div id="pageShell">
<main id="appShell">
<div id="topBar" class="panel app-topbar">
<div class="topbar-heading">
<img class="topbar-logo" src="/logo.png" alt="" aria-hidden="true" />
<div>
<h1 id="welcomeText" class="page-title">Napló</h1>
<p id="routeText" class="page-text"></p>
</div>
</div>
<div class="panel-actions">
<a class="ghost-button" href="/alkalmazas">Vissza az alkalmazáshoz</a>
<button id="logoutButton" class="ghost-button" type="button">Kijelentkezés</button>
</div>
</div>
<section class="panel">
<div class="panel-head">
<div class="section-heading">
<h2>Bejegyzések</h2>
<p>Kereshető és rendezhető ellenőrzési napló a teljes útvonalhoz.</p>
</div>
</div>
<div id="entryControls">
<div class="field-wrap">
<label for="searchInput">Keresés</label>
<input id="searchInput" type="text" placeholder="Lift, helyszín, gyári szám, technikus" />
</div>
<div class="field-wrap">
<label for="sortSelect">Rendezés</label>
<select id="sortSelect">
<option value="inspectionDateDesc">Ellenőrzés dátuma szerint csökkenő</option>
<option value="inspectionDateAsc">Ellenőrzés dátuma szerint növekvő</option>
<option value="nextInspectionAsc">Következő ellenőrzés szerint növekvő</option>
<option value="nextInspectionDesc">Következő ellenőrzés szerint csökkenő</option>
<option value="elevatorNameAsc">Lift neve szerint</option>
<option value="statusAsc">Állapot szerint</option>
</select>
</div>
</div>
<div id="entriesList" class="entries-grid"></div>
</section>
</main>
</div>
<script src="/logs.js?v=20260522-5"></script>
</body>
</html>
+261
View File
@@ -0,0 +1,261 @@
const state = {
user: null,
logs: [],
logsLoaded: false,
initialLogRetryCount: 0,
searchActivated: false
};
const elements = {
welcomeText: document.getElementById("welcomeText"),
routeText: document.getElementById("routeText"),
logoutButton: document.getElementById("logoutButton"),
entriesList: document.getElementById("entriesList"),
searchInput: document.getElementById("searchInput"),
sortSelect: document.getElementById("sortSelect")
};
bindEvents();
bootPage();
function bindEvents() {
elements.logoutButton.addEventListener("click", logout);
elements.searchInput.addEventListener("input", handleSearchInput);
elements.sortSelect.addEventListener("change", loadLogs);
window.addEventListener("pageshow", handlePageShow);
}
async function bootPage() {
state.logsLoaded = false;
state.initialLogRetryCount = 0;
state.searchActivated = false;
elements.searchInput.value = "";
renderLogs();
try {
const response = await apiFetch("/api/me");
state.user = response.user;
fillUserHeader();
await loadLogs(true);
} catch (error) {
if (error.status === 401) {
window.location.href = "/bejelentkezes";
return;
}
elements.welcomeText.textContent = "Betöltési hiba";
elements.routeText.textContent = "A napló nem tölthető be. Frissítsd az oldalt vagy jelentkezz be újra.";
renderError(error.message);
}
}
function fillUserHeader() {
const name = state.user?.fullName || "Felhasználó";
const route = state.user?.routeName || "Nincs megadva";
elements.welcomeText.textContent = "Napló";
elements.routeText.textContent = `${name} · Technikusi útvonal: ${route}`;
}
async function loadLogs(isInitialLoad = false) {
try {
const sortBy = elements.sortSelect.value || "inspectionDateDesc";
const response = await apiFetch(`/api/logs?sortBy=${encodeURIComponent(sortBy)}`);
state.logs = Array.isArray(response.logs)
? response.logs.map((log) => ({
...log,
files: Array.isArray(log.files) ? log.files : []
}))
: [];
state.logsLoaded = true;
renderLogs();
if (isInitialLoad && !state.logs.length) {
scheduleInitialLogRetry();
}
} catch (error) {
state.logs = [];
state.logsLoaded = true;
renderError(error.message);
if (isInitialLoad) {
scheduleInitialLogRetry();
}
}
}
function renderLogs() {
if (!state.logsLoaded) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Betöltés</h3>
<p>A bejegyzések betöltése folyamatban van.</p>
</article>
`;
return;
}
const query = state.searchActivated ? elements.searchInput.value.trim().toLowerCase() : "";
const visibleLogs = state.logs.filter((log) => searchableLog(log).includes(query));
if (!visibleLogs.length) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Nincs találat</h3>
<p>Módosítsd a keresést, vagy hozz létre új ellenőrzési bejegyzést az alkalmazás oldalon.</p>
</article>
`;
return;
}
elements.entriesList.innerHTML = visibleLogs
.map((log) => {
const fileMarkup = log.files.length
? log.files
.map(
(file) => `
<a class="file-link" href="${file.downloadPath}">
${escapeHtml(file.originalName)}
</a>
`
)
.join("")
: `<span class="muted-text">Nincs PDF csatolmány</span>`;
return `
<article class="entry-card">
<div class="card-topline">
<span class="status-chip ${statusClassName(log.status)}">${humanizeStatus(log.status)}</span>
<span class="muted-text">${formatDate(log.inspectionDate)}</span>
</div>
<h3>${escapeHtml(log.elevatorName)}</h3>
<p><strong>Gyári szám:</strong> ${escapeHtml(log.serialNumber)}</p>
<p><strong>Helyszín:</strong> ${escapeHtml(log.location)}</p>
<p><strong>Útvonal:</strong> ${escapeHtml(log.routeName)}</p>
<p><strong>Technikus:</strong> ${escapeHtml(log.technicianName)}</p>
<p><strong>Következő ellenőrzés:</strong> ${formatDate(log.nextInspectionDate)}</p>
<p><strong>Megjegyzés:</strong> ${escapeHtml(log.notes || "Nincs megjegyzés.")}</p>
<div class="file-list">${fileMarkup}</div>
</article>
`;
})
.join("");
}
function renderError(message) {
elements.entriesList.innerHTML = `
<article class="entry-card empty-card">
<h3>Hiba</h3>
<p>${escapeHtml(message)}</p>
</article>
`;
}
function scheduleInitialLogRetry() {
if (state.initialLogRetryCount >= 3 || state.logs.length) {
return;
}
state.initialLogRetryCount += 1;
window.setTimeout(() => {
state.logsLoaded = false;
renderLogs();
loadLogs(false);
}, 500 * state.initialLogRetryCount);
}
function handleSearchInput() {
state.searchActivated = true;
renderLogs();
}
function handlePageShow() {
if (state.user) {
state.logsLoaded = false;
renderLogs();
loadLogs();
}
}
async function logout() {
try {
const response = await apiFetch("/api/logout", { method: "POST" });
window.location.href = response.redirectPath;
} catch (_error) {
window.location.href = "/bejelentkezes";
}
}
async function apiFetch(url, options = {}) {
const headers = options.isFormData ? {} : { "Content-Type": "application/json" };
const response = await fetch(url, {
method: options.method || "GET",
credentials: "include",
headers,
body: options.body
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json()
: { message: "A szerver nem JSON választ adott vissza." };
if (!response.ok) {
const error = new Error(data.message || "A kérés nem sikerült.");
error.status = response.status;
throw error;
}
return data;
}
function searchableLog(log) {
return [
log.elevatorName,
log.serialNumber,
log.location,
log.routeName,
log.technicianName,
log.status,
log.notes
]
.join(" ")
.toLowerCase();
}
function humanizeStatus(status) {
return status === "rendben" ? "Rendben" : status === "figyelmeztetes" ? "Figyelmeztetés" : "Lejárt";
}
function statusClassName(status) {
return status === "rendben" ? "success-chip" : status === "figyelmeztetes" ? "warning-chip" : "danger-chip";
}
function formatDate(value) {
const normalizedDate = normalizeDateInput(value);
return normalizedDate ? new Date(`${normalizedDate}T00:00:00`).toLocaleDateString("hu-HU") : "Nincs dátum";
}
function normalizeDateInput(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return value.includes("T") ? value.slice(0, 10) : value;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? "" : parsedDate.toISOString().slice(0, 10);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regisztráció | LiftEllenőr</title>
<link rel="stylesheet" href="/style.css?v=20260522-3" />
</head>
<body>
<div id="pageShell">
<header id="siteHeader" class="panel">
<a class="brand-block brand-link" href="/">
<img class="brand-logo" src="/logo.png" alt="Csödényi Felvonó Felülvizsgáló logó" />
<div class="brand-copy">
<p class="brand-name">LiftEllenőr</p>
<p class="brand-subtitle">Technikusi hozzáférés és vizsgálati naplózás.</p>
</div>
</a>
<nav class="top-nav" aria-label="Fő navigáció">
<a class="nav-link" href="/">Főoldal</a>
<a class="nav-link" href="/bejelentkezes">Bejelentkezés</a>
<a class="nav-link nav-link-active" href="/regisztracio">Regisztráció</a>
</nav>
</header>
<main id="mainLayout">
<section class="panel auth-panel">
<div class="section-heading">
<h1 class="page-title">Regisztráció</h1>
<p class="page-text">Új technikusi hozzáférés létrehozása.</p>
</div>
<form id="authForm" class="auth-form">
<label for="fullName">Teljes név</label>
<input id="fullName" name="fullName" type="text" required />
<label for="routeName">Technikusi útvonal</label>
<input id="routeName" name="routeName" type="text" placeholder="Például: Dél-Pest" required />
<label for="username">Felhasználónév</label>
<input id="username" name="username" type="text" autocomplete="username" required />
<label for="password">Jelszó</label>
<input id="password" name="password" type="password" autocomplete="new-password" required />
<button class="secondary-button" type="submit">Regisztráció mentése</button>
</form>
<p id="authMessage" class="feedback-text"></p>
<p class="auth-link-row">Van már fiókod? <a href="/bejelentkezes">Bejelentkezés</a></p>
</section>
</main>
</div>
<script src="/auth.js?v=20260522-1"></script>
</body>
</html>
+788
View File
@@ -0,0 +1,788 @@
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--line: #d9dee7;
--line-strong: #c8d0dc;
--text: #1f2937;
--muted: #6b7280;
--accent: #2563eb;
--accent-dark: #1d4ed8;
--warning: #b45309;
--danger: #b91c1c;
--success: #15803d;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--radius: 12px;
}
* {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
font-family: "Segoe UI", Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--text);
background:
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, #f7faff, var(--bg) 18%);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
border: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.75rem 0.85rem;
background: #fff;
color: var(--text);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
textarea {
resize: vertical;
}
label {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
#pageShell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 1.5rem 0 2.5rem;
}
#heroText,
.panel-head p,
#routeText,
.muted-text,
.feedback-text,
.entry-card p,
.dashboard-card p,
.auth-link-row {
color: var(--muted);
}
#mainLayout,
#appShell {
display: grid;
gap: 1rem;
}
.landing-layout {
gap: 1.25rem;
}
.auth-panel {
max-width: 560px;
margin: 0 auto;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.landing-header {
position: sticky;
top: 1rem;
z-index: 2;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98)),
var(--panel);
backdrop-filter: blur(8px);
}
#siteHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
border-color: #d6e2f7;
background:
linear-gradient(135deg, rgba(239, 245, 255, 0.96), rgba(255, 255, 255, 0.98)),
var(--panel);
}
.brand-block {
display: inline-flex;
align-items: center;
gap: 0.9rem;
min-width: 0;
}
.brand-link {
color: inherit;
text-decoration: none;
}
.brand-copy {
min-width: 0;
}
.brand-logo {
width: 52px;
height: 52px;
object-fit: contain;
border-radius: 14px;
flex: 0 0 auto;
background: #fff;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.14);
}
.brand-name {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--accent-dark);
}
.brand-subtitle {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.landing-header .brand-block {
gap: 1rem;
}
.landing-header .brand-copy {
display: grid;
gap: 0.2rem;
}
.landing-header .brand-logo {
width: 68px;
height: 68px;
padding: 0.2rem;
border-radius: 18px;
}
.landing-header .brand-name {
line-height: 1.15;
}
.top-nav {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.nav-link {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.8rem;
color: var(--text);
background: #fff;
text-decoration: none;
font-weight: 600;
}
.nav-link:hover {
background: #f8fafc;
}
.nav-link-active {
border-color: var(--line-strong);
background: #eef4ff;
color: var(--accent-dark);
}
.section-heading {
display: grid;
gap: 0.2rem;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.landing-kicker {
margin: 0 0 0.6rem;
color: var(--accent-dark);
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.landing-title {
margin: 0;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 1.05;
max-width: 12ch;
}
.landing-lead {
margin: 0;
font-size: 1.02rem;
color: var(--muted);
max-width: 68ch;
}
.page-text {
margin: 0;
color: var(--muted);
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.panel-actions,
.inline-actions,
.device-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.panel-head h2,
.entry-card h3,
.dashboard-card h3 {
margin: 0;
}
.panel-head h2 {
font-size: 1.15rem;
}
.tab-button,
.ghost-button,
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
padding: 0.7rem 1rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.tab-button,
.ghost-button {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.primary-button {
background: var(--accent);
color: #fff;
}
.primary-button:hover {
background: var(--accent-dark);
}
.secondary-button {
background: #eef4ff;
color: var(--accent-dark);
border: 1px solid #cfe0ff;
}
.secondary-button:hover {
background: #e3edff;
}
.ghost-button:hover,
.tab-button:hover {
background: #f8fafc;
}
.app-topbar .ghost-button {
border-color: #bfd3fb;
background: rgba(255, 255, 255, 0.88);
}
.app-topbar .ghost-button:hover {
background: #eff5ff;
}
.compact-button {
padding: 0.55rem 0.8rem;
font-size: 0.92rem;
}
.auth-form,
#logForm {
display: grid;
gap: 0.85rem;
}
#deviceForm {
display: grid;
gap: 0.85rem;
}
.hidden {
display: none !important;
}
#topBar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.app-topbar {
border-color: #cfe0ff;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(255, 255, 255, 0.96) 28%),
var(--panel);
}
.topbar-heading {
display: flex;
align-items: center;
gap: 0.9rem;
min-width: 0;
}
.topbar-logo {
width: 50px;
height: 50px;
object-fit: contain;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
padding: 0.2rem;
flex: 0 0 auto;
}
.landing-hero {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(240px, 0.8fr);
gap: 1rem;
align-items: stretch;
padding: 1.5rem;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.1), transparent 32%),
linear-gradient(180deg, #ffffff, #f7f9fc);
}
.landing-hero-copy {
display: grid;
gap: 1rem;
}
.landing-hero-logo {
width: min(100%, 320px);
max-height: 320px;
justify-self: center;
align-self: center;
object-fit: contain;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 36px rgba(37, 99, 235, 0.16);
padding: 0.6rem;
}
.hero-sidecard {
display: grid;
align-content: start;
gap: 0.9rem;
padding: 1rem;
border: 1px solid #dbe4f0;
border-radius: 14px;
background: linear-gradient(180deg, #eef4ff, #ffffff);
}
.hero-sidecard-label {
margin: 0;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-dark);
}
.hero-sidecard-text {
margin: 0;
color: var(--text);
font-weight: 600;
}
.landing-login-link {
padding: 0.45rem 0.75rem;
font-size: 0.92rem;
border-color: #bfd3fb;
color: var(--accent-dark);
background: rgba(255, 255, 255, 0.9);
}
.landing-feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.landing-feature-card,
.landing-service-item {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
.landing-feature-card {
padding: 1rem;
}
.landing-feature-card h3,
.landing-service-item h3 {
margin: 0 0 0.55rem;
}
.landing-feature-card p,
.landing-service-item p,
.landing-cta-text {
margin: 0;
color: var(--muted);
}
.landing-service-list {
display: grid;
gap: 0.9rem;
}
.landing-service-item {
padding: 1rem 1rem 1rem 1.15rem;
border-left: 4px solid #cfe0ff;
}
.landing-cta {
display: grid;
gap: 1rem;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(255, 255, 255, 1) 38%),
#fff;
}
.landing-contact-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
color: var(--text);
font-weight: 600;
}
.dashboard-grid,
.entries-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.dashboard-card,
.entry-card {
padding: 1rem;
border-radius: 10px;
background: #fff;
border: 1px solid var(--line);
min-height: 100%;
}
.empty-card {
display: grid;
place-items: center;
text-align: center;
min-height: 180px;
}
.card-topline {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.8rem;
flex-wrap: wrap;
}
.status-chip,
.serial-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.3rem 0.65rem;
font-size: 0.82rem;
font-weight: 600;
}
.serial-chip {
background: #f3f4f6;
color: var(--text);
}
.success-chip {
background: #eaf7ef;
color: var(--success);
}
.warning-chip {
background: #fff4e5;
color: var(--warning);
}
.danger-chip {
background: #fdecec;
color: var(--danger);
}
.countdown-box {
margin: 1rem 0 0.75rem;
padding: 0.85rem 0.95rem;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--line);
font-size: 1rem;
font-weight: 700;
}
.form-grid,
#entryControls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
#entryControls {
margin-bottom: 0.75rem;
}
.field-wrap {
display: grid;
gap: 0.45rem;
}
.readonly-input {
background: #f8fafc;
color: var(--muted);
}
.field-wide {
grid-column: 1 / -1;
}
.form-actions {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.feedback-text {
min-height: 1.25rem;
margin: 0;
}
.feedback-success {
color: var(--success);
}
.feedback-error {
color: var(--danger);
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.summary-card {
padding: 0.95rem 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--text);
line-height: 1.6;
}
.device-card {
display: grid;
}
.device-actions {
margin-top: 1rem;
}
.modal-open {
overflow: hidden;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.52);
}
.modal-panel {
width: min(760px, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
border: 1px solid #d6e2f7;
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98)),
#fff;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
padding: 1.25rem;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-close {
flex: 0 0 auto;
}
.file-link {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.7rem;
border-radius: 8px;
border: 1px solid var(--line);
background: #f8fafc;
color: var(--accent-dark);
text-decoration: none;
}
.file-link:hover {
background: #eef4ff;
border-color: #cfe0ff;
}
.auth-link-row {
margin: 0.4rem 0 0;
}
.auth-link-row a {
color: var(--accent-dark);
font-weight: 600;
text-decoration: none;
}
.auth-link-row a:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.landing-hero,
.landing-feature-grid,
.dashboard-grid,
.entries-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
#pageShell {
width: min(100% - 1rem, 1180px);
padding-top: 1rem;
}
#siteHeader,
#topBar,
.panel-head,
.form-actions,
.modal-head {
display: grid;
align-items: start;
}
.brand-block,
.topbar-heading {
align-items: start;
}
.landing-hero,
.landing-feature-grid,
.form-grid,
#entryControls,
.dashboard-grid,
.entries-grid {
grid-template-columns: 1fr;
}
#mainTitle {
max-width: none;
font-size: 1.6rem;
}
.landing-title {
max-width: none;
}
.landing-hero-logo {
width: min(100%, 220px);
max-height: 220px;
}
.brand-logo,
.topbar-logo {
width: 44px;
height: 44px;
}
}