23 Commits

Author SHA1 Message Date
a3df8172e2 Merge branch 'demoDev' into debian12Demo 2026-02-22 23:50:32 +01:00
98834a9270 Merge branch 'dev' into demoDev 2026-02-22 23:50:00 +01:00
c8a230979f changed version info 2026-02-22 23:45:38 +01:00
581cd4a1fd changed link of help wiki 2026-02-22 23:41:25 +01:00
c53e6e095a updated api documentation 2026-02-22 23:33:30 +01:00
eaa325668c Implemented page header and added note column 2026-02-22 23:21:33 +01:00
c00f720af4 Merge branch 'demoDev' into debian12Demo 2026-02-20 16:34:21 +01:00
8d04465705 Merge branch 'dev' into demoDev 2026-02-20 16:33:54 +01:00
1fa8b4a9a7 refactor: clean up layout components and improve footer styling 2026-02-20 16:30:20 +01:00
3ba3c1c0cb improved error logging for the api route to return or take loans 2026-02-20 16:22:13 +01:00
ee54d51f8b enhanced loan management: added note field to loan creation and email templates 2026-02-20 12:14:56 +01:00
a8dab549af fixed bug: cannot return loan 2026-02-20 12:02:33 +01:00
06976f7972 added pasword input to admin panel 2026-02-14 19:01:59 +01:00
06cb298a38 Merge branch 'demoDev' into debian12Demo 2026-02-09 15:55:52 +01:00
8589971dc8 added scheme to demo branch 2026-02-09 15:55:27 +01:00
6ec8e19737 Merge branch 'dev' into demoDev 2026-02-09 15:51:25 +01:00
977a6c1b16 deltedt mock data because its too old 2026-02-09 15:51:00 +01:00
38c647c62f Fixed bug/issue: #13 2026-02-09 15:49:51 +01:00
757b316b49 fixed translation bug 2026-02-09 13:42:19 +01:00
d05e9ab3ee deleted unused changelog 2026-02-09 13:42:06 +01:00
d29c793b6b Merge branch 'demoDev' into debian12Demo 2026-02-07 17:50:53 +01:00
9f44a4796d Merge branch 'dev' into demoDev 2026-02-07 17:50:34 +01:00
3f59ed6951 removed line 2026-02-07 17:48:23 +01:00
20 changed files with 457 additions and 656 deletions

View File

@@ -1,366 +1,177 @@
# Borrow System API Documentation
**Frontend:** https://insta.the1s.de
**Backend base URL:** `https://insta.the1s.de/backend/api`
## Overview
---
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.
## Authentication
All API endpoints require **either**:
### 1. Bearer Token (JWT)
Send an `Authorization` header:
```http
Authorization: Bearer <JWT_TOKEN>
```
- Used for user-based access.
- Token must be valid and not expired.
### 2. API Key (for devices / machine-to-machine)
Include an API key in the route as `:key` parameter:
```text
/api/.../:key/...
```
Example:
```http
GET /api/items/12345678
```
Where `12345678` is your API key.
The API key is validated server-side.
---
## Common Response Codes
- `200 OK` Request was successful.
- `401 Unauthorized` Missing or malformed credentials.
- `403 Forbidden` Credentials invalid or not allowed to access this resource.
- `404 Not Found` Resource (e.g., loan) not found.
- `500 Internal Server Error` Unexpected server error.
---
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
## Endpoints
### 1. Get All Items
The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
**GET** `/api/items/:key`
### Get All Items
Returns a list of all items.
`GET /items/:key`
#### Path Parameters
Returns all items in the system.
- `:key` API key (8-digit number)
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/12345678 HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": [
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"inSafe": 1,
"item_name": "Laptop",
"can_borrow_role": 1,
"in_safe": true,
"safe_nr": 3,
"door_key": "123",
"entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"door_key": 101,
"last_borrowed_person": "jdoe",
"currently_borrowing": null
}
]
}
```
#### Error Response (500)
**Response 500:**
```json
{
"message": "Failed to fetch items"
}
{ "message": "Failed to fetch items" }
```
---
### 2. Toggle Item Safe State
### Change Item Safe State
Toggles `in_safe` between `0` and `1` for a given item.
`POST /change-state/:key/:itemId`
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
Toggles the `in_safe` boolean state of an item.
**POST** `/api/change-state/:key/:itemId`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **itemId** - The item's ID
- `:key` API key (8-digit number)
- `:itemId` Item ID (integer)
**Response 200:** Returns on successful toggle.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/12345678/42 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
```
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
{ "message": "Failed to update item state" }
```
---
### 3. Get Loan by Code
### Get Loan by Code
Fetch loan information by `loan_code`.
`GET /get-loan-by-code/:key/:loan_code`
**GET** `/api/get-loan-by-code/:key/:loan_code`
Retrieves loan details by its 6-digit loan code.
#### Path Parameters
**URL Parameters:**
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": {
"username": "john",
"username": "jdoe",
"returned_date": null,
"take_date": "2025-01-01T10:00:00.000Z",
"lockers": "[1, 2, 3]"
"take_date": "2024-01-15T10:30:00.000Z",
"lockers": [1, 3]
}
}
```
#### Error Response (404)
**Response 404:**
```json
{
"message": "Loan not found"
}
{ "message": "Loan not found" }
```
---
### 4. Set Loan Return Date
### Set Take Date
Sets `returned_date = NOW()` on a loan and updates related items:
`POST /set-take-date/:key/:loan_code`
- `in_safe = 1`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
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.
**POST** `/api/set-return-date/:key/:loan_code`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Loan not found or already taken" }
```
#### Error Response (500)
```json
{
"message": "Failed to set return date"
}
```
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
---
### 5. Set Loan Take Date
### Set Return Date
Sets `take_date = NOW()` on a loan and updates related items:
`POST /set-return-date/:key/:loan_code`
- `in_safe = 0`
- `currently_borrowing = username`
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.
**POST** `/api/set-take-date/:key/:loan_code`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/12345678/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Failed to set return date" }
```
#### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
---
### 6. Open Door by Door Key
### Open Door
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
`GET /open-door/:key/:doorKey`
**GET** `/api/open-door/:key/:doorKey`
Toggles the safe state of an item identified by its door key and returns the associated safe number.
#### Path Parameters
**URL Parameters:**
- `:key` API key (8-digit number)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
- **key** - API key
- **doorKey** - The door key identifier assigned to an item
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/12345678/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": {
"safe_nr": 5,
"id": 42
"safe_nr": 3,
"id": 1
}
}
```
#### Error Response (500)
**Response 500:**
```json
{
"message": "Failed to open door"
}
{ "message": "Failed to open door" }
```
---
## Error Handling
## Authentication Error Messages
### Missing credentials
Status: `401`
```json
{
"message": "Unauthorized"
}
```
### Invalid JWT
Status: `403`
```json
{
"message": "Present token invalid"
}
```
### Invalid API Key
Status: `403`
```json
{
"message": "API Key invalid"
}
```
---
## Notes
- All responses are JSON.
- Time fields like `take_date` and `returned_date` are in the format returned by MySQL (usually ISO-like strings).
- `loaned_items_id` in the database is stored as a JSON array string (e.g. `"[1,2,3]"`) and is parsed internally; clients do not interact with this field directly via current endpoints.
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.

View File

@@ -12,7 +12,7 @@ import { triggerLogoutAtom } from "@/states/Atoms";
import { MyLoansPage } from "./pages/MyLoansPage";
import Landingpage from "./pages/Landingpage";
import { changeLanguage } from "i18next";
import { Box, Flex } from "@chakra-ui/react";
import { 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";
@@ -71,8 +71,8 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh">
<Box as="main" flex="1">
<Flex direction="column" minH="100dvh">
<Flex as="main" flex="1" direction="column">
<UserContext.Provider value={user}>
<BrowserRouter>
<Routes>
@@ -86,7 +86,7 @@ function App() {
</Routes>
</BrowserRouter>
</UserContext.Provider>
</Box>
</Flex>
<Footer />
</Flex>
</QueryClientProvider>

View File

@@ -1,50 +0,0 @@
{
"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)."
]
}
]
}
]
}

View File

@@ -1,263 +0,0 @@
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>
);
}

View File

@@ -141,7 +141,7 @@ export const Header = () => {
value="help"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
"_blank",
"noopener,noreferrer",
)
@@ -268,7 +268,7 @@ export const Header = () => {
</Button>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
target="_blank"
>
<Button variant="ghost">

View File

@@ -9,10 +9,9 @@ export const Footer = () => {
as="footer"
py={4}
textAlign="center"
position="fixed"
bottom="0"
left="0"
right="0"
width="100%"
flexShrink={0}
fontSize="sm"
>
Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br />

View File

@@ -9,12 +9,13 @@ 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 { useNavigate } from "react-router-dom";
import { Header } from "@/components/Header";
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
@@ -32,6 +33,7 @@ type Loan = {
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
note: string | null;
};
type Device = {
@@ -46,7 +48,6 @@ type Device = {
const Landingpage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -85,7 +86,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_loan")
t("unexpected-date-format_loan"),
);
}
@@ -102,7 +103,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_device")
t("unexpected-date-format_device"),
);
}
} catch (e) {
@@ -115,14 +116,8 @@ const Landingpage: React.FC = () => {
}, []);
return (
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Container className="px-6 sm:px-8 pt-10">
<Header />
<Heading as="h2" size="md" mb={4}>
{t("all-loans")}
@@ -168,6 +163,9 @@ 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>
@@ -184,6 +182,7 @@ 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>
@@ -260,7 +259,7 @@ const Landingpage: React.FC = () => {
</HStack>
</Button>
</HStack>
</>
</Container>
);
};

View File

@@ -7,7 +7,6 @@ import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } 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 = () => {
@@ -71,7 +70,7 @@ export const LoginPage = () => {
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
@@ -115,7 +114,6 @@ export const LoginPage = () => {
</Card.Footer>
</Card.Root>
</form>
<Footer />
</div>
);
};

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:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
"An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
};
}
@@ -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",

View File

@@ -84,6 +84,7 @@
"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.",

View File

@@ -82,6 +82,7 @@
"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",

View File

@@ -3,6 +3,7 @@ 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("");
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<Input
type="password"
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>

View File

@@ -0,0 +1,159 @@
"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" }
}
}

View File

@@ -1,10 +1,11 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
@@ -23,14 +24,10 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Chakra / Pfad Aliases */
"baseUrl": ".",
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "6.0"
}
},
"include": ["src"]
}

View File

@@ -1,9 +1,9 @@
{
"backend-info": {
"version": "v2.1 (demo)"
"version": "v2.1.1 (demo)"
},
"frontend-info": {
"version": "v2.1 (demo)"
"version": "v2.1.2 (demo)"
},
"admin-panel-info": {
"version": "v1.3.2 (demo)"

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,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
};
export const setReturnDateV2 = async (loanCode) => {
try {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
"SELECT loaned_items_id, username 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 };
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 [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]
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
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" };
}
return { success: false };
};
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 };
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
@@ -93,18 +105,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 = ?",
[loanCode]
"UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
return { message: "Failed to set take date", success: false };
};
export const getAllLoansV2 = async () => {
@@ -118,12 +130,12 @@ export const getAllLoansV2 = async () => {
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey]
[doorKey],
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id]
[result[0].id],
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };

View File

@@ -47,7 +47,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 +58,11 @@ router.post(
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
res.status(200).json({});
} 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,11 +73,11 @@ router.post(
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
res.status(200).json({});
} else {
res.status(500).json({ message: "Failed to set take date" });
}
res.status(500).json({ message: result.message });
}
},
);
// Route for API to open a door

View File

@@ -62,6 +62,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
mailInfo.data.note,
);
return res.status(201).json({
message: "Loan created successfully",

View File

@@ -34,7 +34,14 @@ const formatDateTime = (value) => {
return "N/A";
};
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
function buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
@@ -116,6 +123,12 @@ function buildLoanEmail({ user, items, startDate, endDate, 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;">
@@ -134,7 +147,14 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
function buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
@@ -145,10 +165,18 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
].join("\n");
}
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
export function sendMailLoan(
user,
items,
startDate,
endDate,
createdDate,
note,
) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
@@ -170,8 +198,16 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
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);

100
backendV2/schemeV2.mock.sql Normal file
View File

@@ -0,0 +1,100 @@
USE borrow_system_new;
-- USERS
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
VALUES
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
-- ITEMS
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
VALUES
('Item1', 1, true, 1, 101, NULL, NULL),
('Item2', 1, true, 2, 102, 'user1', 'user1'),
('Item3', 2, true, 3, 103, 'user2', NULL),
('Item4', 1, false, NULL, NULL, NULL, NULL),
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
-- LOANS
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
created_at,
loaned_items_id,
loaned_items_name,
deleted,
note
)
VALUES
(
'user1',
JSON_ARRAY('Locker1', 'Locker2'),
'123456',
'2026-02-01 09:00:00',
'2026-02-10 17:00:00',
'2026-02-01 09:15:00',
NULL,
'2026-02-01 09:00:00',
JSON_ARRAY(1, 2),
JSON_ARRAY('Item1', 'Item2'),
false,
'Erste allgemeine Ausleihe'
),
(
'user2',
JSON_ARRAY('Locker3'),
'234567',
'2026-02-02 10:00:00',
'2026-02-05 16:00:00',
'2026-02-02 10:05:00',
'2026-02-05 15:30:00',
'2026-02-02 10:00:00',
JSON_ARRAY(3),
JSON_ARRAY('Item3'),
false,
'Zurückgegeben vor Enddatum'
),
(
'user3',
JSON_ARRAY(),
'345678',
'2026-02-03 08:30:00',
'2026-02-15 18:00:00',
NULL,
NULL,
'2026-02-03 08:30:00',
JSON_ARRAY(5),
JSON_ARRAY('Item5'),
false,
'Noch ausgeliehen'
),
(
'user1',
JSON_ARRAY('Locker4'),
'456789',
'2025-12-01 09:00:00',
'2025-12-03 17:00:00',
'2025-12-01 09:10:00',
'2025-12-03 16:45:00',
'2025-12-01 09:00:00',
JSON_ARRAY(1),
JSON_ARRAY('Item1'),
true,
'Alte, gelöschte Ausleihe'
);
-- API KEYS
INSERT INTO apiKeys (api_key, entry_name)
VALUES
('10000001', 'Entry1'),
('10000002', 'Entry2'),
('10000003', 'Entry3'),
('10000004', 'Entry4');