Compare commits

..

9 Commits

Author SHA1 Message Date
theis.gaedigk 8c701db900 changed ports 2025-11-23 21:11:23 +01:00
theis.gaedigk d1664338a6 add networks configuration for frontend and backend services in docker-compose 2025-11-23 21:06:12 +01:00
theis.gaedigk 1a2624cd9e again 2025-11-23 20:34:19 +01:00
theis.gaedigk a138190cc6 fixed bugs 2025-11-23 20:32:14 +01:00
theis.gaedigk 993e0cd74b fixed bugs 2025-11-23 20:29:31 +01:00
theis.gaedigk dab004a7b6 changed docker config 2025-11-23 20:26:27 +01:00
theis.gaedigk d039336f39 Merge branch 'dev' into debian12 2025-11-23 20:20:41 +01:00
theis.gaedigk 4c781e9325 changed ports 2025-11-23 20:12:41 +01:00
theis.gaedigk 451e6b3646 published v2 2025-11-23 20:11:36 +01:00
56 changed files with 1171 additions and 1334 deletions
-9
View File
@@ -113,12 +113,3 @@ secrets/
keys/
ToDo.txt
# only in development branch
next-env.d.ts
# psd files from footage
footage/*.psd
icon/
+241 -73
View File
@@ -1,177 +1,345 @@
# Borrow System API Documentation
# Backend API (V2) Documentation
## Overview
This document describes the current backend API routes and their real response shapes, based on the code in `backendV2`.
The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
---
## Base URLs
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path: `https://backend.insta.the1s.de/api`
Service status: `https://status.the1s.de`
---
## Authentication
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
All **protected** endpoints require an API key as a path parameter `:key`.
## Endpoints
Rules for `:key`:
The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
- Exactly 8 characters
- Digits only (`^[0-9]{8}$`)
### Get All Items
Example:
`GET /items/:key`
```http
GET /api/items/12345678
```
Returns all items in the system.
On missing / invalid key:
**Response 200:**
- Status: `401 Unauthorized`
- Body (exact message depends on `authenticate` in `backendV2/services/authentication.js`)
Auth-related modules:
- `backendV2/services/authentication.js`
- `backendV2/services/database.js`
Route handlers:
- `backendV2/routes/api/api.route.js`
- `backendV2/routes/api/api.database.js`
---
## Endpoints (Overview)
1. **Public**
- `GET /api/all-items` List all items (no auth; from original docs)
2. **Items (authenticated)**
- `GET /api/items/:key` List all items
- `POST /api/change-state/:key/:itemId/:state` Toggle item safe state
3. **Loans (authenticated)**
- `GET /api/get-loan-by-code/:key/:loan_code` Get loan by code
- `POST /api/set-take-date/:key/:loan_code` Set “take” date and mark items as out
- `POST /api/set-return-date/:key/:loan_code` Set “return” date and mark items as returned
---
## 1) Items
### 1.1 Get all items
**GET** `/api/items/:key`
Returns all items wrapped in a `data` property.
- Handler: `getItemsFromDatabaseV2` in `api.database.js`
- SQL: `SELECT * FROM items;`
#### Example request
```http
GET https://backend.insta.the1s.de/api/items/12345678
```
#### Successful response
```json
{
"data": [
{
"id": 1,
"item_name": "Laptop",
"can_borrow_role": 1,
"in_safe": true,
"safe_nr": 3,
"door_key": 101,
"last_borrowed_person": "jdoe",
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"in_safe": 1,
"safe_nr": "01",
"entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"currently_borrowing": null
}
]
}
```
**Response 500:**
#### Error response
```json
{ "message": "Failed to fetch items" }
```
#### Status codes
- `200 OK` success, `data` is an array (possibly empty)
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database error or `success: false` from DB layer
---
### Change Item Safe State
### 2.2 Toggle item safe state
`POST /change-state/:key/:itemId`
**POST** `/api/change-state/:key/:itemId`
Toggles the `in_safe` boolean state of an item.
> You do not need this endpoint to set the states of the items when the items are taken out or returned. When you take or return a loan, the item states are set automatically by the loan endpoints. This endpoint is only for manually toggling the `inSafe` state of an item.
**URL Parameters:**
Path parameters:
- **key** - API key
- **itemId** - The item's ID
- `:key` API key (8 digits)
- `:itemId` numeric `id` of the item
**Response 200:** Returns on successful toggle.
Handler in `api.route.js` calls `changeInSafeStateV2(itemId)`, which executes:
**Response 500:**
```sql
UPDATE items SET in_safe = NOT in_safe WHERE id = ?
```
#### Example request
```http
POST https://backend.insta.the1s.de/api/change-state/12345678/42
```
(Will toggle `in_safe` for item `42`.)
#### Successful response (current implementation)
```json
{
"data": null
}
```
#### Error responses
Invalid `state` (anything other than `"0"` or `"1"`):
```json
{ "message": "Invalid state value" }
```
Failed update:
```json
{ "message": "Failed to update item state" }
```
#### Status codes
- `200 OK` item state toggled
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database/update failure or `success: false` from DB layer
---
### Get Loan by Code
## 3) Loans
`GET /get-loan-by-code/:key/:loan_code`
### 3.1 Get loan by code
Retrieves loan details by its 6-digit loan code.
**GET** `/api/get-loan-by-code/:key/:loan_code`
**URL Parameters:**
Path parameters:
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key
- `:loan_code` 6-digit loan code (`^[0-9]{6}$` per DB constraint)
**Response 200:**
Database layer (`getLoanByCodeV2`) currently selects:
```sql
SELECT first_name, returned_date, take_date, lockers
FROM loans
WHERE loan_code = ?;
```
#### Example request
```http
GET https://backend.insta.the1s.de/api/get-loan-by-code/12345678/646473
```
#### Successful response
```json
{
"data": {
"username": "jdoe",
"first_name": "Theis",
"returned_date": null,
"take_date": "2024-01-15T10:30:00.000Z",
"lockers": [1, 3]
"take_date": "2025-08-25T13:23:00.000Z",
"lockers": ["01", "03"]
}
}
```
**Response 404:**
#### Error response
```json
{ "message": "Loan not found" }
```
#### Status codes
- `200 OK` loan found
- `401 Unauthorized` invalid / missing key
- `404 Not Found` no matching loan for this `loan_code`
---
### Set Take Date
### 3.2 Set take date
`POST /set-take-date/:key/:loan_code`
**POST** `/api/set-take-date/:key/:loan_code`
Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
Path parameters:
**URL Parameters:**
- `:key` API key
- `:loan_code` loan code
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
#### Example request
**Response 200:** Empty JSON object on success.
**Response 500:**
```json
{ "message": "Loan not found or already taken" }
```http
POST https://backend.insta.the1s.de/api/set-take-date/12345678/646473
```
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
#### Successful response
```json
{
"data": null
}
```
#### Error response
```json
{ "message": "Failed to set take date" }
```
#### Status codes
- `200 OK` take date set and items marked as out
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
---
### Set Return Date
### 3.3 Set return date
`POST /set-return-date/:key/:loan_code`
**POST** `/api/set-return-date/:key/:loan_code`
Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
Path parameters:
**URL Parameters:**
- `:key` API key
- `:loan_code` loan code
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
#### Example request
**Response 200:** Empty JSON object on success.
```http
POST https://backend.insta.the1s.de/api/set-return-date/12345678/646473
```
**Response 500:**
#### Successful response (current implementation)
```json
{
"data": null
}
```
#### Error response
```json
{ "message": "Failed to set return date" }
```
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
#### Status codes
- `200 OK` return date set and items marked as returned
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
---
### Open Door
## Common Response Shapes
`GET /open-door/:key/:doorKey`
**Success list (authenticated items):**
Toggles the safe state of an item identified by its door key and returns the associated safe number.
```json
{
"data": [
/* array of rows */
]
}
```
**URL Parameters:**
- **key** - API key
- **doorKey** - The door key identifier assigned to an item
**Response 200:**
**Success single loan:**
```json
{
"data": {
"safe_nr": 3,
"id": 1
/* selected loan fields */
}
}
```
**Response 500:**
**Success mutations (current code):**
```json
{ "message": "Failed to open door" }
{ "data": null }
```
## Error Handling
**Errors:**
All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
```json
{ "message": "Failed to fetch items" }
{ "message": "Failed to update item state" }
{ "message": "Invalid state value" }
{ "message": "Loan not found" }
{ "message": "Failed to set return date" }
{ "message": "Failed to set take date" }
```
**HTTP Status Codes:**
- `200 OK` operation succeeded
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid/missing API key
- `404 Not Found` loan not found
- `500 Internal Server Error` database / server failure or `success: false` from DB layer
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

+2 -6
View File
@@ -2,13 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/png"
href="/icon_borrow-system-frontend_dark.png"
/>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausleihsystem</title>
<title>frontendv2</title>
</head>
<body>
<div id="root"></div>
-8
View File
@@ -9,14 +9,6 @@ server {
try_files $uri $uri/ /index.html;
}
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8004/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
access_log off;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 420 B

+4 -6
View File
@@ -12,11 +12,10 @@ import { triggerLogoutAtom } from "@/states/Atoms";
import { MyLoansPage } from "./pages/MyLoansPage";
import Landingpage from "./pages/Landingpage";
import { changeLanguage } from "i18next";
import { Flex } from "@chakra-ui/react";
import { Box, Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
import { ContactPage } from "./pages/ContactPage";
const queryClient = new QueryClient();
@@ -72,8 +71,8 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100dvh">
<Flex as="main" flex="1" direction="column">
<Flex direction="column" minH="100vh">
<Box as="main" flex="1">
<UserContext.Provider value={user}>
<BrowserRouter>
<Routes>
@@ -81,14 +80,13 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landingpage" element={<Landingpage />} />
<Route path="/contact" element={<ContactPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
</BrowserRouter>
</UserContext.Provider>
</Flex>
</Box>
<Footer />
</Flex>
</QueryClientProvider>
+50
View File
@@ -0,0 +1,50 @@
{
"title": "Changelog",
"items": [
{
"version": "v2.1.0",
"date": "2025-10-24",
"changes": [
{
"type": "Hinzugefügt",
"text": [
"Neue Changelog-Komponente mit zentriertem Layout.",
"Unterstützung für mehrsprachige Einträge (Englisch und Deutsch)."
]
},
{
"type": "Verbessert",
"text": [
"Performance-Optimierungen beim Laden der Listenansichten.",
"Verbesserte Barrierefreiheit durch ARIA-Attribute."
]
},
{
"type": "Behoben",
"text": [
"Fehler bei der Datumsauswahl im Safari-Browser.",
"Anzeigeprobleme bei hohen DPI-Einstellungen."
]
}
]
},
{
"version": "v2.0.3",
"date": "2025-10-10",
"changes": [
{
"type": "Geändert",
"text": [
"Standard-Timeout für API-Requests auf 10s erhöht."
]
},
{
"type": "Sicherheit",
"text": [
"Abhängigkeiten aktualisiert (kritische CVEs behoben)."
]
}
]
}
]
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useRef, useState } from "react";
const STORAGE_KEY = "changelog";
type ChangeType =
| "Hinzugefügt"
| "Geändert"
| "Behoben"
| "Entfernt"
| "Verbessert"
| "Sicherheit"
| "Veraltet"
| string;
type ChangeEntry = {
type: ChangeType;
text: string | string[]; // aus localStorage kann es eine Liste sein
};
type ChangelogItem = {
version?: string;
date: string;
changes: ChangeEntry[];
};
type StoredChangelog = {
title: string;
items: ChangelogItem[];
};
const typeStyles: Record<string, string> = {
Hinzugefügt:
"bg-emerald-500/15 text-emerald-300 ring-1 ring-inset ring-emerald-500/30",
Geändert: "bg-blue-500/15 text-blue-300 ring-1 ring-inset ring-blue-500/30",
Behoben: "bg-amber-500/15 text-amber-300 ring-1 ring-inset ring-amber-500/30",
Entfernt: "bg-rose-500/15 text-rose-300 ring-1 ring-inset ring-rose-500/30",
Verbessert:
"bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30",
Sicherheit: "bg-red-500/15 text-red-300 ring-1 ring-inset ring-red-500/30",
Veraltet: "bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40",
};
export default function Changelog() {
const [open, setOpen] = useState(true);
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<StoredChangelog | null>(null);
const [error, setError] = useState<string | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setMounted(true), []);
const loadFromStorage = () => {
try {
setError(null);
const raw =
typeof window !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (!raw) {
setData(null);
return;
}
const parsed = JSON.parse(raw) as StoredChangelog;
if (!parsed || !Array.isArray(parsed.items)) {
throw new Error("Ungültiges Format");
}
setData(parsed);
} catch (e) {
setError("Changelog konnte nicht aus localStorage geladen werden.");
setData(null);
}
};
useEffect(() => {
loadFromStorage();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onClickOutside = (e: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) loadFromStorage();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClickOutside);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClickOutside);
window.removeEventListener("storage", onStorage);
};
}, []);
if (!open) return null;
const title = data?.title ?? "Changelog";
const items = data?.items ?? [];
return (
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(60%_60%_at_50%_0%,rgba(99,102,241,0.12),rgba(24,24,27,0))] flex items-center justify-center p-6">
<div
ref={cardRef}
className={[
"relative w-full max-w-6xl transition-all duration-300 ease-out",
mounted
? "opacity-100 translate-y-0 scale-100"
: "opacity-0 translate-y-1 scale-[0.99]",
].join(" ")}
aria-live="polite"
>
{/* Gradient border wrapper */}
<div className="rounded-2xl p-[1px] bg-gradient-to-b from-zinc-700/60 via-zinc-700/20 to-zinc-800/60 shadow-2xl">
{/* Card */}
<div className="relative rounded-[calc(theme(borderRadius.2xl)-1px)] border border-zinc-800/70 bg-zinc-900/70 supports-[backdrop-filter]:bg-zinc-900/60 backdrop-blur-xl ring-1 ring-white/10">
{/* Accent top line */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent" />
{/* Close button */}
<button
aria-label="Changelog schließen"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 transition"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
{/* Header */}
<header className="px-10 pt-8 pb-6 border-b border-zinc-800/70">
<div className="flex items-center gap-3">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />
</svg>
</div>
<div>
<h1 className="text-[30px] leading-8 font-semibold text-zinc-100 tracking-[-0.01em]">
{title}
</h1>
<p className="text-sm text-zinc-400">
Aktuelle Änderungen und Updates
</p>
</div>
</div>
</header>
{/* Body */}
<div className="relative max-h-[78vh] overflow-y-auto">
<div className="absolute pointer-events-none inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-900/70 to-transparent" />
<div className="absolute pointer-events-none inset-x-0 bottom-0 h-10 bg-gradient-to-t from-zinc-900/80 to-transparent" />
{error && (
<div className="px-10 py-8">
<div className="rounded-lg border border-red-900/40 bg-red-900/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
</div>
)}
{!error && items.length === 0 && (
<div className="px-10 py-16 text-center">
<p className="text-zinc-400">
Kein Changelog im localStorage gefunden (Key: {STORAGE_KEY}
).
</p>
</div>
)}
<ul className="divide-y divide-zinc-800/70">
{items.map((entry, idx) => (
<li
key={`${entry.version ?? entry.date}-${idx}`}
className="px-10 py-8"
>
{/* Kopfzeile je Release */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
{entry.version && (
<span className="inline-flex items-center rounded-md bg-gradient-to-b from-zinc-100 to-zinc-300 text-zinc-900 px-3 py-0.5 text-sm font-semibold shadow-sm">
{entry.version}
</span>
)}
<time
className="text-sm text-zinc-400"
dateTime={entry.date}
>
{new Date(entry.date).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "2-digit",
})}
</time>
</div>
{/* Zweispaltiges Layout: Typ links, Text rechts (mit schöner Leselänge) */}
<dl
role="list"
className="mt-6 grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-[max-content_1fr]"
>
{entry.changes.map((c, i) => (
<div key={i} className="contents">
<dt className="md:w-44 md:justify-end md:text-right">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${
typeStyles[c.type] ??
"bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40"
}`}
>
{c.type}
</span>
</dt>
<dd className="max-w-[74ch] text-[15px] leading-7 text-zinc-200 tracking-[0.005em]">
{Array.isArray(c.text) ? (
<ul className="ml-4 list-disc marker:text-zinc-500/70 space-y-1.5">
{c.text.map((t, k) => (
<li key={k} className="break-words">
{t}
</li>
))}
</ul>
) : (
<p className="break-words">{c.text}</p>
)}
</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</div>
{/* soft bottom glow */}
<div className="pointer-events-none absolute inset-x-12 -bottom-4 h-8 blur-2xl bg-indigo-600/20 rounded-full" />
</div>
</div>
</div>
</div>
);
}
+220 -22
View File
@@ -4,41 +4,98 @@ import {
Heading,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
IconButton,
Menu,
Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie";
import { useAtom } from "jotai";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useNavigate } from "react-router-dom";
import {
CircleUserRound,
RotateCcwKey,
Code,
LifeBuoy,
LogOut,
CalendarPlus,
MoreVertical,
Languages,
Table,
ContactRound,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { UserDialogue } from "./UserDialogue";
import { API_BASE } from "@/config/api.config";
export const Header = () => {
const navigate = useNavigate();
const userData = useUserContext();
console.log(userData);
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [userDialog, setUserDialog] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
const username = userData.first_name ? userData.first_name : "N/A";
const fullname = userData.first_name + " " + userData.last_name;
const randomColor = [
@@ -142,9 +199,9 @@ export const Header = () => {
value="help"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
"_blank",
"noopener,noreferrer",
"noopener,noreferrer"
)
}
children={
@@ -155,12 +212,18 @@ export const Header = () => {
}
/>
<Menu.Item
value="contact"
onSelect={() => navigate("/contact", { replace: true })}
value="source-code"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<ContactRound size={16} />
<Text as="span">{t("contact")}</Text>
<Code size={16} />
<Text as="span">{t("source-code")}</Text>
</HStack>
}
/>
@@ -279,7 +342,7 @@ export const Header = () => {
</Button>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
target="_blank"
>
<Button variant="ghost">
@@ -290,15 +353,17 @@ export const Header = () => {
</Button>
</a>
<Button
variant={"outline"}
onClick={() => navigate("/contact", { replace: true })}
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
target="_blank"
>
<HStack gap={2}>
<ContactRound size={18} />
<Text as="span">{t("contact")}</Text>
</HStack>
</Button>
<Button variant="ghost">
<HStack gap={2}>
<Code size={18} />
<Text as="span">{t("source-code")}</Text>
</HStack>
</Button>
</a>
<Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}>
@@ -311,12 +376,145 @@ export const Header = () => {
{/* User Info Dialoge */}
{userDialog && (
<UserDialogue
setUserDialog={setUserDialog}
fullname={fullname}
randomColor={randomColor}
/>
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">
{userData.is_admin ? t("yes") : t("no")}
</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
</Flex>
)}
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Stack>
);
};
-220
View File
@@ -1,220 +0,0 @@
import {
Button,
Flex,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import { RotateCcwKey } from "lucide-react";
import MyAlert from "./myChakra/MyAlert";
import { API_BASE } from "@/config/api.config";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Cookies from "js-cookie";
type UserDialogueProps = {
setUserDialog: (value: boolean) => void;
fullname: string;
randomColor: string[];
};
export const UserDialogue = (props: UserDialogueProps) => {
const userData = useUserContext();
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
return (
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={props.randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={props.fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">{userData.is_admin ? t("yes") : t("no")}</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Flex>
);
};
+5 -4
View File
@@ -9,11 +9,12 @@ export const Footer = () => {
as="footer"
py={4}
textAlign="center"
width="100%"
flexShrink={0}
fontSize="sm"
position="fixed"
bottom="0"
left="0"
right="0"
>
Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
Made with by Theis Gaedigk - Year 2019 at MCS-Bochum
<br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"}
+16 -9
View File
@@ -1,15 +1,22 @@
"use client"
"use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import * as React from "react";
import type { ReactNode } from "react";
export function Provider(props: ColorModeProviderProps) {
export interface ColorModeProviderProps {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// add real color-mode logic here if you need it
return <>{children}</>;
}
export function Provider({ children }: { children: ReactNode }) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
<ColorModeProvider>{children}</ColorModeProvider>
</ChakraProvider>
)
);
}
-84
View File
@@ -1,84 +0,0 @@
import {
Field,
Textarea,
Button,
Alert,
Container,
Text,
} from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { Header } from "@/components/Header";
interface Alert {
type: "info" | "warning" | "success" | "error" | "neutral";
headline: string;
text: string;
}
export const ContactPage = () => {
const { t } = useTranslation();
const [message, setMessage] = useState("");
const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => {
// Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ message }),
});
if (result.ok) {
setAlert({
type: "success",
headline: t("contactPage_successHeadline"),
text: t("contactPage_successText"),
});
setMessage("");
} else {
setAlert({
type: "error",
headline: t("contactPage_errorHeadline"),
text: t("contactPage_errorText"),
});
}
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
<Field.Root invalid={message === ""}>
<Field.Label>
<Text>{t("contactPage_messageDescription")}</Text>
<Field.RequiredIndicator />
</Field.Label>
<Textarea
placeholder={t("contactPage_messagePlaceholder")}
variant="subtle"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
{message === "" && (
<Field.ErrorText>{t("contactPage_messageErrorText")}</Field.ErrorText>
)}
</Field.Root>
{alert && (
<Alert.Root status={alert.type}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{alert.headline}</Alert.Title>
<Alert.Description>{alert.text}</Alert.Description>
</Alert.Content>
</Alert.Root>
)}
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container>
);
};
+1
View File
@@ -108,6 +108,7 @@ export const HomePage = () => {
}
setBorrowableItems(response.data);
setIsMsg(false);
console.log(borrowableItems);
});
}}
>
+14 -13
View File
@@ -9,13 +9,12 @@ import {
Card,
SimpleGrid,
Button,
Container,
} from "@chakra-ui/react";
import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { Header } from "@/components/Header";
import { useNavigate } from "react-router-dom";
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
@@ -33,7 +32,6 @@ type Loan = {
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
note: string | null;
};
type Device = {
@@ -48,6 +46,7 @@ type Device = {
const Landingpage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
@@ -60,7 +59,7 @@ const Landingpage: React.FC = () => {
const setError = (
status: "error" | "success",
message: string,
description: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
@@ -86,7 +85,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_loan"),
t("unexpected-date-format_loan")
);
}
@@ -103,7 +102,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_device"),
t("unexpected-date-format_device")
);
}
} catch (e) {
@@ -116,8 +115,14 @@ const Landingpage: React.FC = () => {
}, []);
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}>
{t("all-loans")}
@@ -163,9 +168,6 @@ const Landingpage: React.FC = () => {
<Table.ColumnHeader>
<strong>{t("return-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("note")}</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -182,7 +184,6 @@ const Landingpage: React.FC = () => {
</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
</Table.Row>
))}
</Table.Body>
@@ -259,7 +260,7 @@ const Landingpage: React.FC = () => {
</HStack>
</Button>
</HStack>
</Container>
</>
);
};
+9 -9
View File
@@ -4,9 +4,10 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai";
import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next";
import { Footer } from "@/components/footer/Footer";
import { API_BASE } from "@/config/api.config";
export const LoginPage = () => {
@@ -15,15 +16,13 @@ export const LoginPage = () => {
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
useEffect(() => {
if (isLoggedIn) {
navigate(from, { replace: true });
window.location.reload(); // if deleted, the user context is not updated in time
navigate("/", { replace: true });
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden
}
}, [isLoggedIn, navigate, from]);
}, [isLoggedIn, navigate]);
const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -62,15 +61,15 @@ export const LoginPage = () => {
return;
}
setTriggerLogout(false);
navigate(from, { replace: true });
navigate("/", { replace: true });
};
if (isLoggedIn) {
return <Navigate to={from} replace />;
return <Navigate to="/" replace />;
}
return (
<div className="flex flex-1 items-center justify-center p-4">
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
@@ -114,6 +113,7 @@ export const LoginPage = () => {
</Card.Footer>
</Card.Root>
</form>
<Footer />
</div>
);
};
+2 -107
View File
@@ -112,86 +112,6 @@ export const MyLoansPage = () => {
return `${d}.${M}.${y} ${h}:${min}`;
};
const handleTakeAction = async (loanCode: string) => {
try {
const res = await fetch(
`${API_BASE}/api/loans/set-take-date/${loanCode}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-take-loan"));
setIsMsg(true);
return;
}
// Update the loan in state
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, take_date: new Date().toISOString() }
: loan,
),
);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("take-loan-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error"));
setIsMsg(true);
}
};
const handleReturnAction = async (loanCode: string) => {
try {
const res = await fetch(
`${API_BASE}/api/loans/set-return-date/${loanCode}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-return-loan"));
setIsMsg(true);
return;
}
// Update the loan in state
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, returned_date: new Date().toISOString() }
: loan,
),
);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("return-loan-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error"));
setIsMsg(true);
}
};
return (
<>
<Container className="px-6 sm:px-8 pt-10">
@@ -270,33 +190,8 @@ export const MyLoansPage = () => {
: "-"}
</Text>
</Table.Cell>
<Table.Cell>
{loan.take_date ? (
formatDate(loan.take_date)
) : (
<Button
size="xs"
colorPalette="teal"
onClick={() => handleTakeAction(loan.loan_code)}
>
{t("take")}
</Button>
)}
</Table.Cell>
<Table.Cell>
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<Button
size="xs"
colorPalette="blue"
onClick={() => handleReturnAction(loan.loan_code)}
disabled={!loan.take_date}
>
{t("return")}
</Button>
)}
</Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
<Table.Cell>
<Dialog.Root role="alertdialog">
+3 -3
View File
@@ -3,7 +3,7 @@ import { API_BASE } from "@/config/api.config";
export const getBorrowableItems = async (
startDate: string,
endDate: string,
endDate: string
) => {
try {
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
@@ -22,7 +22,7 @@ export const getBorrowableItems = async (
status: "error",
title: "Server error",
description:
"An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
};
}
@@ -48,7 +48,7 @@ export const createLoan = async (
itemIds: number[],
startDate: string,
endDate: string,
note: string | null,
note: string | null
) => {
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST",
+2 -18
View File
@@ -63,7 +63,7 @@
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
"optional-note": "Optionale Notiz",
"note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und ändern.",
"role": "Rolle",
"admin-status": "Admin-Status",
"first-name": "Vorname",
@@ -72,21 +72,5 @@
"last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen",
"landingpage": "Übersichtsseite",
"contactPage_successHeadline": "Nachricht erfolgreich gesendet",
"contactPage_successText": "Vielen Dank, dass Sie uns kontaktiert haben. Wir werden uns so schnell wie möglich bei Ihnen melden.",
"contactPage_errorHeadline": "Fehler beim Senden der Nachricht",
"contactPage_errorText": "Beim Senden Ihrer Nachricht ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"contactPage_sendButton": "Nachricht senden",
"contactPage_messageLabel": "Nachricht",
"contactPage_messagePlaceholder": "Geben Sie hier Ihre Nachricht ein...",
"contactPage_messageErrorText": "Dieses Feld darf nicht leer sein.",
"contact": "Kontakt",
"take": "Abholen",
"return": "Zurückgeben",
"serverError": "Serverfehler. Bitte versuchen Sie es später erneut, oder laden Sie die Seite neu.",
"take-loan-success": "Ausleihe erfolgreich abgeholt",
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
"landingpage": "Übersichtsseite"
}
+2 -18
View File
@@ -63,7 +63,7 @@
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
"optional-note": "Optional note",
"note": "Note",
"user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
"user-info-desc": "Here you can view and edit your personal information.",
"role": "Role",
"admin-status": "Admin status",
"first-name": "First name",
@@ -72,21 +72,5 @@
"last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by",
"back": "Go back",
"landingpage": "Overview page",
"contactPage_successHeadline": "Message sent successfully",
"contactPage_successText": "Thank you for contacting us. We will get back to you as soon as possible.",
"contactPage_errorHeadline": "Error sending message",
"contactPage_errorText": "An error occurred while sending your message. Please try again later.",
"contactPage_sendButton": "Send message",
"contactPage_messageLabel": "Message",
"contactPage_messagePlaceholder": "Enter your message here...",
"contactPage_messageErrorText": "This field cannot be empty.",
"contact": "Contact",
"serverError": "Server error. Please try again later, or refresh the page.",
"take": "Take",
"return": "Return",
"take-loan-success": "Loan taken successfully",
"return-loan-success": "Loan returned successfully",
"network-error": "Network error. Please contact the administrator.",
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
"landingpage": "Overview page"
}
+14 -7
View File
@@ -1,16 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
port: 8001,
watch: {
usePolling: true,
allowedHosts: ["insta.the1s.de"],
port: 8101,
watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
},
},
});
-73
View File
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 theis.gaedigk
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/user-star.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adminpanel</title>
<title>Admin panel</title>
</head>
<body>
<div id="root"></div>
-8
View File
@@ -9,14 +9,6 @@ server {
try_files $uri $uri/ /index.html;
}
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8004/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
access_log off;
+40 -28
View File
@@ -3675,16 +3675,12 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cosmiconfig": {
@@ -4470,9 +4466,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4908,9 +4904,9 @@
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -4919,6 +4915,21 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5296,9 +5307,9 @@
}
},
"node_modules/react-router": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz",
"integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -5318,12 +5329,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz",
"integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.0"
"react-router": "7.8.2"
},
"engines": {
"node": ">=20.0.0"
@@ -5481,9 +5492,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
@@ -5638,15 +5649,16 @@
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"license": "BlueOak-1.0.0",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
+2 -2
View File
@@ -3,7 +3,6 @@ import { useState } from "react";
import { loginFunc } from "@/utils/loginUser";
import MyAlert from "../components/myChakra/MyAlert";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [username, setUsername] = useState("");
@@ -44,7 +43,8 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<PasswordInput
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
+1
View File
@@ -63,6 +63,7 @@ const APIKeyTable: React.FC = () => {
}
);
const data = await response.json();
console.log(data);
return data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
+8 -6
View File
@@ -29,8 +29,8 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Input id="item_name" placeholder="z.B. Laptop" />
</Field.Root>
<Field.Root>
<Field.Label>Schließfachnummer</Field.Label>
<Input id="safe_nr" placeholder="Nummer 1 - 6" />
<Field.Label>Schließfachnummer (immer zwei Zahlen)</Field.Label>
<Input id="lockerNumber" placeholder="Nummer 01 - 06" />
</Field.Root>
<Field.Root>
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
@@ -57,15 +57,17 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
(document.getElementById("can_borrow_role") as HTMLInputElement)
?.value
);
const safeNrValue = (
document.getElementById("safe_nr") as HTMLInputElement
const lockerValue = (
document.getElementById("lockerNumber") as HTMLInputElement
)?.value.trim();
const safeNr = safeNrValue === "" ? null : safeNrValue;
const lockerNumber =
lockerValue === "" ? null : Number(lockerValue);
if (!name || Number.isNaN(role)) return;
if (lockerNumber !== null && Number.isNaN(lockerNumber)) return;
const res = await createItem(name, role, safeNr);
const res = await createItem(name, role, lockerNumber);
if (res.success) {
alert(
"success",
+5 -31
View File
@@ -38,7 +38,6 @@ type Items = {
can_borrow_role: string;
in_safe: boolean;
safe_nr: string;
door_key: string;
entry_created_at: string;
entry_updated_at: string;
last_borrowed_person: string | null;
@@ -69,13 +68,7 @@ const ItemTable: React.FC = () => {
const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
);
};
const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
prev.map((it) => (it.id === id ? { ...it, lockerNumber: value } : it))
);
};
@@ -193,12 +186,7 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */}
{!isLoading && (
<Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
@@ -213,12 +201,9 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<Table.ColumnHeader>
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
@@ -231,7 +216,7 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader>
<strong>Dav **</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
@@ -305,28 +290,17 @@ const ItemTable: React.FC = () => {
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Table.Cell>
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role
).then((response) => {
if (response.success) {
+1
View File
@@ -85,6 +85,7 @@ const UserTable: React.FC = () => {
setIsLoading(true);
try {
const data = await fetchUserData();
console.log(data);
if (Array.isArray(data)) {
setUsers(data);
} else {
-159
View File
@@ -1,159 +0,0 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import * as React from "react"
import { LuEye, LuEyeOff } from "react-icons/lu"
export interface PasswordVisibilityProps {
/**
* The default visibility state of the password input.
*/
defaultVisible?: boolean
/**
* The controlled visibility state of the password input.
*/
visible?: boolean
/**
* Callback invoked when the visibility state changes.
*/
onVisibleChange?: (visible: boolean) => void
/**
* Custom icons for the visibility toggle button.
*/
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
}
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
)
})
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = React.forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}
+5 -4
View File
@@ -165,8 +165,9 @@ export const deleteItem = async (itemId: number) => {
export const createItem = async (
item_name: string,
can_borrow_role: number,
lockerNumber: string | null
lockerNumber: number | null
) => {
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
try {
const response = await fetch(
`${API_BASE}/api/admin/item-data/create-item`,
@@ -183,7 +184,7 @@ export const createItem = async (
return {
success: false,
message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.",
};
}
return { success: true };
@@ -197,9 +198,9 @@ export const handleEditItems = async (
itemId: number,
item_name: string,
safe_nr: string | null,
door_key: string | null,
can_borrow_role: string
) => {
const newSafeNr = Number(safe_nr || 0);
try {
const response = await fetch(
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
@@ -209,7 +210,7 @@ export const handleEditItems = async (
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
body: JSON.stringify({ item_name, newSafeNr, can_borrow_role }),
}
);
if (!response.ok) {
+7 -4
View File
@@ -1,11 +1,10 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
@@ -24,10 +23,14 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
/* Chakra / Pfad Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "5.0"
},
"include": ["src"]
}
+7 -3
View File
@@ -8,9 +8,13 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8003,
watch: {
usePolling: true,
allowedHosts: ["admin.insta.the1s.de"],
port: 8103,
watch: { usePolling: true },
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
},
},
});
+3 -3
View File
@@ -1,11 +1,11 @@
{
"backend-info": {
"version": "v2.1.1 (dev)"
"version": "v2.0"
},
"frontend-info": {
"version": "v2.1.2 (dev)"
"version": "v2.0"
},
"admin-panel-info": {
"version": "v1.3.2 (dev)"
"version": "v1.2"
}
}
@@ -32,22 +32,10 @@ export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
return { success: false };
};
export const editItemById = async (
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
) => {
let newSafeNr;
if (safe_nr === null || safe_nr === "") {
newSafeNr = null;
} else {
newSafeNr = safe_nr;
}
export const editItemById = async (itemId, item_name, can_borrow_role) => {
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, newSafeNr, door_key, itemId]
"UPDATE items SET item_name = ?, can_borrow_role = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, itemId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
@@ -18,11 +18,11 @@ export const createUser = async (
isAdmin,
email,
first_name,
last_name,
last_name
) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
[username, role, password, isAdmin, email, first_name, last_name],
[username, role, password, isAdmin, email, first_name, last_name]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
@@ -34,10 +34,10 @@ export const deleteUserById = async (userId) => {
return { success: false };
};
export const changePassword = async (username, newPassword) => {
export const changePassword = async (userId, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ?",
[newPassword, username],
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE id = ?",
[newPassword, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
@@ -49,11 +49,11 @@ export const editUserById = async (
last_name,
role,
email,
is_admin,
is_admin
) => {
const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
[first_name, last_name, role, email, is_admin, userId],
[first_name, last_name, role, email, is_admin, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
@@ -61,7 +61,7 @@ export const editUserById = async (
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users",
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
@@ -70,7 +70,7 @@ export const getAllUsers = async () => {
export const getUserById = async (userId) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
[userId],
[userId]
);
if (rows.length === 0) {
return { success: false };
+2 -5
View File
@@ -41,14 +41,11 @@ router.post("/create-item", authenticateAdmin, async (req, res) => {
router.post("/edit-item/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id;
const { item_name, can_borrow_role, safe_nr, door_key } = req.body;
const { item_name, can_borrow_role } = req.body;
const result = await editItemById(
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
can_borrow_role
);
if (result.success) {
return res.status(200).json({ message: "Item edited successfully" });
+42 -73
View File
@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
[loan_code],
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId],
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
@@ -42,62 +42,50 @@ export const changeInSafeStateV2 = async (itemId) => {
};
export const setReturnDateV2 = async (loanCode) => {
try {
const [items] = await pool.query(
"SELECT loaned_items_id, username FROM loans WHERE loan_code = ?",
[loanCode],
);
if (items.length === 0)
return { success: false, message: "No items found for loan" };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
[loanCode],
);
if (result.affectedRows === 0) return { success: false };
if (itemIds.length > 0) {
await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = ? WHERE id IN (?)",
[items[0].username, itemIds],
);
}
return { success: true, data: { returned: true } };
} catch (error) {
console.error("setReturnDateV2 error:", error);
return { success: false, message: "Failed to set return date" };
}
};
export const setTakeDateV2 = async (loanCode) => {
const [isTaken] = await pool.query(
"SELECT take_date FROM loans WHERE loan_code = ?",
[loanCode],
);
if (isTaken.length === 0 || isTaken[0].take_date !== null) {
return { success: false, message: "Loan not found or already taken" };
}
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode],
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode],
[loanCode]
);
if (items.length === 0)
return { success: false, message: "No items found for loan" };
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
@@ -105,18 +93,18 @@ export const setTakeDateV2 = async (loanCode) => {
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds],
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
[loanCode],
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { message: "Failed to set take date", success: false };
return { success: false };
};
export const getAllLoansV2 = async () => {
@@ -126,22 +114,3 @@ export const getAllLoansV2 = async () => {
}
return { success: false };
};
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey],
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id],
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
} else {
return { success: false };
}
}
return { success: false };
};
+6 -19
View File
@@ -10,7 +10,6 @@ import {
setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2,
openDoor,
} from "./api.database.js";
// Route for API to get all items from the database
@@ -47,7 +46,7 @@ router.get(
} else {
res.status(404).json({ message: "Loan not found" });
}
},
}
);
// Route for API to set the return date by the loan code
@@ -58,11 +57,11 @@ router.post(
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({});
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
},
}
);
// Route for API to set the take away date by the loan code
@@ -73,23 +72,11 @@ router.post(
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({});
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: result.message });
res.status(500).json({ message: "Failed to set take date" });
}
},
}
);
// Route for API to open a door
router.get("/open-door/:key/:doorKey", authenticate, async (req, res) => {
const doorKey = req.params.doorKey;
const result = await openDoor(doorKey);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to open door" });
}
});
export default router;
@@ -16,7 +16,7 @@ export const createLoanInDatabase = async (
startDate,
endDate,
note,
itemIds,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
@@ -52,7 +52,7 @@ export const createLoanInDatabase = async (
// Ensure all items exist and collect names + lockers
const [itemsRows] = await conn.query(
"SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
[itemIds],
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
@@ -65,24 +65,16 @@ export const createLoanInDatabase = async (
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name,
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Build lockers array (unique, only 2-digit numbers from safe_nr)
// Build lockers array (unique, only 2-digit strings)
const lockers = [
...new Set(
itemsRows
.map((r) => r.safe_nr)
.filter(
(sn) =>
sn !== null &&
sn !== undefined &&
Number.isInteger(Number(sn)) &&
Number(sn) >= 0 &&
Number(sn) <= 99,
)
.map((sn) => Number(sn)),
.filter((sn) => typeof sn === "string" && /^\d{2}$/.test(sn))
),
];
@@ -98,7 +90,7 @@ export const createLoanInDatabase = async (
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start],
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
@@ -115,7 +107,7 @@ export const createLoanInDatabase = async (
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate],
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
@@ -146,7 +138,7 @@ export const createLoanInDatabase = async (
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
note,
],
]
);
await conn.commit();
@@ -189,7 +181,7 @@ export const getLoanInfoWithID = async (loanId) => {
export const getLoansFromDatabase = async (username) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username],
[username]
);
if (result.length > 0) {
return { success: true, status: true, data: result };
@@ -202,7 +194,7 @@ export const getLoansFromDatabase = async (username) => {
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
@@ -236,7 +228,7 @@ export const getBorrowableItemsFromDatabase = async (
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId],
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
@@ -260,69 +252,3 @@ export const getItems = async () => {
}
return { success: false };
};
export const setReturnDate = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode],
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode],
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds],
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDate = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode],
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode],
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds],
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
+3 -26
View File
@@ -13,8 +13,6 @@ import {
getALLLoans,
getItems,
SETdeleteLoanFromDatabase,
setReturnDate,
setTakeDate,
} from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js";
@@ -50,7 +48,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
start,
end,
note,
itemIds,
itemIds
);
if (result.success) {
@@ -61,8 +59,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
mailInfo.data.note,
mailInfo.data.created_at
);
return res.status(201).json({
message: "Loan created successfully",
@@ -99,26 +96,6 @@ router.get("/loans", authenticate, async (req, res) => {
}
});
router.post("/set-return-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
});
router.post("/set-take-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
});
router.get("/all-items", authenticate, async (req, res) => {
const result = await getItems();
if (result.success) {
@@ -158,7 +135,7 @@ router.post("/borrowable-items", authenticate, async (req, res) => {
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role,
req.user.role
);
if (result.success) {
// return the array directly for consistency with /items
+10 -77
View File
@@ -2,53 +2,14 @@ import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
function buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`,
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
@@ -108,27 +69,21 @@ function buildLoanEmail({
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate,
startDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate,
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate,
createdDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
note || "Keine Notiz"
}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
@@ -147,14 +102,7 @@ function buildLoanEmail({
</html>`;
}
function buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
@@ -165,18 +113,10 @@ function buildLoanEmailText({
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
].join("\n");
}
export function sendMailLoan(
user,
items,
startDate,
endDate,
createdDate,
note,
) {
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
@@ -198,18 +138,11 @@ export function sendMailLoan(
startDate,
endDate,
createdDate,
note,
}),
html: buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
console.log("Loan message sent:", info.messageId);
console.log("Message sent:", info.messageId);
})();
console.log("sendMailLoan called");
}
@@ -1,43 +0,0 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
export function sendMail(username, message) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const mailText = `Neue Kontaktanfrage im Ausleihsystem.\n\nBenutzername: ${username}\n\nNachricht:\n${message}`;
const mailHtml = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Neue Nachricht im Ausleihsystem</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
<h2>Neue Nachricht im Ausleihsystem</h2>
<p><strong>Benutzername:</strong> ${username}</p>
<p><strong>Nachricht:</strong></p>
<p style="white-space: pre-line;">${message}</p>
</body>
</html>`;
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES_CONTACT,
subject: "Sie haben eine neue Nachricht!",
text: mailText,
html: mailHtml,
});
console.log("Contact message sent: %s", info.messageId);
})();
}
-10
View File
@@ -6,7 +6,6 @@ dotenv.config();
// database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
import { sendMail } from "./services/mailer_v2.js";
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
@@ -36,13 +35,4 @@ router.post("/change-password", authenticate, async (req, res) => {
}
});
router.post("/contact", authenticate, async (req, res) => {
const message = req.body.message;
const username = req.user.username;
sendMail(username, message);
res.status(200).json({ message: "Contact message sent successfully" });
});
export default router;
Binary file not shown.
+120
View File
@@ -0,0 +1,120 @@
USE borrow_system_new;
-- Reset tables (no FKs defined, so order is safe)
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE loans;
TRUNCATE TABLE apiKeys;
TRUNCATE TABLE items;
TRUNCATE TABLE users;
SET FOREIGN_KEY_CHECKS = 1;
-- Users (roles 16, plain-text passwords; is_admin is BOOL)
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin) VALUES
('admin', 'adminpass', 'admin@example.com', 'System', 'Admin', 6, TRUE),
('alice', 'alice123', 'alice@example.com', 'Alice', 'Andersen',1, FALSE),
('bob', 'bob12345', 'bob@example.com', 'Bob', 'Berg', 2, FALSE),
('carol', 'carol123', 'carol@example.com', 'Carol', 'Christensen', 3, FALSE),
('dave', 'dave123', 'dave@example.com', 'Dave', 'Dahl', 4, FALSE),
('erin', 'erin123', 'erin@example.com', 'Erin', 'Enevoldsen', 5, FALSE),
('frank', 'frank123', 'frank@example.com', 'Frank', 'Fisher', 2, FALSE),
('grace', 'grace123', 'grace@example.com', 'Grace', 'Gundersen',1, FALSE),
('heidi', 'heidi123', 'heidi@example.com', 'Heidi', 'Hansen', 4, FALSE),
('tech', 'techpass', 'tech@example.com', 'Tech', 'User', 5, TRUE);
-- Items (safe_nr is two digits or NULL; matches CHECK and UNIQUE constraint)
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, last_borrowed_person, currently_borrowing) VALUES
('Laptop A', 2, FALSE, NULL, 'grace', 'bob'),
('Laptop B', 2, TRUE, '01', NULL, NULL),
('Camera Canon', 3, TRUE, '02', 'erin', NULL),
('Microphone Rode', 1, TRUE, '03', 'grace', NULL),
('Tripod Manfrotto', 1, TRUE, '04', 'frank', NULL),
('Oscilloscope Tek', 4, TRUE, '05', NULL, NULL),
('VR Headset', 3, FALSE, NULL, 'heidi', 'carol'),
('Keycard Programmer', 6, TRUE, '06', 'admin', NULL);
-- Loans (JSON strings, 6-digit numeric loan_code per CHECK)
-- Assumes the items above have ids 1..8 in insert order
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
loaned_items_id,
loaned_items_name,
deleted,
note
) VALUES
-- Active loan: bob has Laptop A (item id 1, locker "01")
('bob',
'["01"]',
'123456',
'2025-11-15 09:00:00',
'2025-11-22 17:00:00',
'2025-11-15 09:15:00',
NULL,
'[1]',
'["Laptop A"]',
FALSE,
'Active loan - Laptop A'
),
-- Returned loan: frank had Tripod Manfrotto (item id 5, locker "04")
('frank',
'["04"]',
'234567',
'2025-10-01 10:00:00',
'2025-10-07 16:00:00',
'2025-10-01 10:05:00',
'2025-10-05 15:30:00',
'[5]',
'["Tripod Manfrotto"]',
FALSE,
'Completed loan'
),
-- Future reservation: dave will take Oscilloscope Tek (item id 6, locker "05")
('dave',
'["05"]',
'345678',
'2025-12-10 09:00:00',
'2025-12-12 17:00:00',
NULL,
NULL,
'[6]',
'["Oscilloscope Tek"]',
FALSE,
'Reserved'
),
-- Active loan: carol has VR Headset (item id 7, locker "02")
('carol',
'["02"]',
'456789',
'2025-11-10 13:00:00',
'2025-11-20 12:00:00',
'2025-11-10 13:10:00',
NULL,
'[7]',
'["VR Headset"]',
FALSE,
'Active loan - VR Headset'
),
-- Soft-deleted historic loan: grace had Microphone + Tripod (item ids 4,5; lockers "03","04")
('grace',
'["03","04"]',
'567890',
'2025-09-01 09:00:00',
'2025-09-03 17:00:00',
'2025-09-01 09:10:00',
'2025-09-03 16:45:00',
'[4,5]',
'["Microphone Rode","Tripod Manfrotto"]',
TRUE,
'Canceled/soft-deleted record'
);
-- API keys (8-digit numeric keys per CHECK)
INSERT INTO apiKeys (api_key, entry_name, last_used_at) VALUES
('12345678', 'CI token', '2025-11-15 08:00:00'),
('87654321', 'Local dev', NULL),
('00000001', 'Monitoring', '2025-11-10 12:30:00');
+3 -3
View File
@@ -37,13 +37,13 @@ CREATE TABLE items (
item_name varchar(255) NOT NULL UNIQUE,
can_borrow_role INT NOT NULL,
in_safe bool NOT NULL DEFAULT true,
safe_nr INT DEFAULT NULL UNIQUE,
door_key INT DEFAULT NULL UNIQUE,
safe_nr CHAR(2) DEFAULT NULL UNIQUE,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_borrowed_person varchar(255) DEFAULT NULL,
currently_borrowing varchar(255) DEFAULT NULL,
PRIMARY KEY (id)
PRIMARY KEY (id),
CHECK (safe_nr REGEXP '^[0-9]{2}$' OR safe_nr IS NULL)
) ENGINE=InnoDB;
CREATE TABLE apiKeys (
+1 -1
View File
@@ -20,7 +20,7 @@ import apiRouter from "./routes/api/api.route.js";
env.config();
const app = express();
const port = 8004;
const port = 8102;
app.use(cors());
// Body-Parser VOR den Routen registrieren
+30 -28
View File
@@ -1,23 +1,32 @@
services:
# usr-frontend_v2:
# container_name: borrow_system-usr-frontend
# build: ./FrontendV2
# ports:
# - "8001:80"
# restart: unless-stopped
usr-frontend_v2:
container_name: borrow_system-usr-frontend
networks:
- proxynet
- borrow_system-internal
build: ./FrontendV2
ports:
- "8101:80"
restart: unless-stopped
# admin-frontend:
# container_name: borrow_system-admin-frontend
# build: ./admin
# ports:
# - "8003:80"
# restart: unless-stopped
admin-frontend:
container_name: borrow_system-admin-frontend
networks:
- proxynet
- borrow_system-internal
build: ./admin
ports:
- "8103:80"
restart: unless-stopped
backend_v2:
container_name: borrow_system-backend_v2
networks:
- proxynet
- borrow_system-internal
build: ./backendV2
ports:
- "8004:8004"
- "8102:8102"
environment:
NODE_ENV: production
DB_HOST: mysql_v2
@@ -30,6 +39,8 @@ services:
mysql_v2:
container_name: borrow_system-mysql-v2
networks:
- borrow_system-internal
image: mysql:8.0
restart: unless-stopped
environment:
@@ -42,21 +53,12 @@ services:
ports:
- "3310:3306"
postgresql:
image: postgres
container_name: borrow_system-postgresql
shm_size: 128mb
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD}
adminer:
container_name: borrow_system-adminer
image: adminer
restart: unless-stopped
ports:
- "8080:8080"
volumes:
mysql-data:
mysql-v2-data:
networks:
proxynet:
external: true
borrow_system-internal:
external: false