Compare commits
15 Commits
v2.1.1
...
dev_postgr
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c9eba726b | |||
| a355c42964 | |||
| c8a230979f | |||
| 581cd4a1fd | |||
| c53e6e095a | |||
| eaa325668c | |||
| 1fa8b4a9a7 | |||
| 3ba3c1c0cb | |||
| ee54d51f8b | |||
| a8dab549af | |||
| 06976f7972 | |||
| 977a6c1b16 | |||
| 38c647c62f | |||
| 757b316b49 | |||
| d05e9ab3ee |
@@ -1,366 +1,177 @@
|
|||||||
# Borrow System API Documentation
|
# Borrow System API Documentation
|
||||||
|
|
||||||
**Frontend:** https://insta.the1s.de
|
## Overview
|
||||||
**Backend base URL:** `https://insta.the1s.de/backend/api`
|
|
||||||
|
|
||||||
---
|
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
|
## Authentication
|
||||||
|
|
||||||
All API endpoints require **either**:
|
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoints
|
## 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)
|
**Response 200:**
|
||||||
|
|
||||||
#### 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"item_name": "DJI 1er Mikro",
|
"item_name": "Laptop",
|
||||||
"can_borrow_role": 4,
|
"can_borrow_role": 1,
|
||||||
"inSafe": 1,
|
"in_safe": true,
|
||||||
"safe_nr": 3,
|
"safe_nr": 3,
|
||||||
"door_key": "123",
|
"door_key": 101,
|
||||||
"entry_created_at": "2025-08-19T22:02:16.000Z",
|
"last_borrowed_person": "jdoe",
|
||||||
"entry_updated_at": "2025-08-19T22:02:16.000Z",
|
|
||||||
"last_borrowed_person": "alice",
|
|
||||||
"currently_borrowing": null
|
"currently_borrowing": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
**Response 500:**
|
||||||
|
|
||||||
```json
|
```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)
|
**Response 200:** Returns on successful toggle.
|
||||||
- `:itemId` – Item ID (integer)
|
|
||||||
|
|
||||||
#### Authentication
|
**Response 500:**
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "message": "Failed to update item state" }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
|
|
||||||
|
|
||||||
#### Error Response (500)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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)
|
- **key** - API key
|
||||||
- `:loan_code` – Loan code (string)
|
- **loan_code** - A 6-digit numeric loan code
|
||||||
|
|
||||||
#### Authentication
|
**Response 200:**
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"username": "john",
|
"username": "jdoe",
|
||||||
"returned_date": null,
|
"returned_date": null,
|
||||||
"take_date": "2025-01-01T10:00:00.000Z",
|
"take_date": "2024-01-15T10:30:00.000Z",
|
||||||
"lockers": "[1, 2, 3]"
|
"lockers": [1, 3]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (404)
|
**Response 404:**
|
||||||
|
|
||||||
```json
|
```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`
|
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.
|
||||||
- `currently_borrowing = NULL`
|
|
||||||
- `last_borrowed_person = 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)
|
**Response 200:** Empty JSON object on success.
|
||||||
- `:loan_code` – Loan code (string)
|
|
||||||
|
|
||||||
#### Authentication
|
**Response 500:**
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "message": "Loan not found or already taken" }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Failed to set return date"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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`
|
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.
|
||||||
- `currently_borrowing = username`
|
|
||||||
|
|
||||||
**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)
|
**Response 200:** Empty JSON object on success.
|
||||||
- `:loan_code` – Loan code (string)
|
|
||||||
|
|
||||||
#### Authentication
|
**Response 500:**
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "message": "Failed to set return date" }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Failed to set take date"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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)
|
- **key** - API key
|
||||||
- `:doorKey` – Door key/token (string) used by hardware to identify the locker.
|
- **doorKey** - The door key identifier assigned to an item
|
||||||
|
|
||||||
#### Authentication
|
**Response 200:**
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"safe_nr": 5,
|
"safe_nr": 3,
|
||||||
"id": 42
|
"id": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
**Response 500:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "message": "Failed to open door" }
|
||||||
"message": "Failed to open door"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Error Handling
|
||||||
|
|
||||||
## Authentication Error Messages
|
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.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { triggerLogoutAtom } from "@/states/Atoms";
|
|||||||
import { MyLoansPage } from "./pages/MyLoansPage";
|
import { MyLoansPage } from "./pages/MyLoansPage";
|
||||||
import Landingpage from "./pages/Landingpage";
|
import Landingpage from "./pages/Landingpage";
|
||||||
import { changeLanguage } from "i18next";
|
import { changeLanguage } from "i18next";
|
||||||
import { Box, Flex } from "@chakra-ui/react";
|
import { Flex } from "@chakra-ui/react";
|
||||||
import { Footer } from "./components/footer/Footer";
|
import { Footer } from "./components/footer/Footer";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { API_BASE } from "@/config/api.config";
|
import { API_BASE } from "@/config/api.config";
|
||||||
@@ -72,8 +72,8 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Flex direction="column" minH="100vh">
|
<Flex direction="column" minH="100dvh">
|
||||||
<Box as="main" flex="1">
|
<Flex as="main" flex="1" direction="column">
|
||||||
<UserContext.Provider value={user}>
|
<UserContext.Provider value={user}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -88,7 +88,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
</Box>
|
</Flex>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Flex>
|
</Flex>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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)."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -142,7 +142,7 @@ export const Header = () => {
|
|||||||
value="help"
|
value="help"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
window.open(
|
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",
|
"_blank",
|
||||||
"noopener,noreferrer",
|
"noopener,noreferrer",
|
||||||
)
|
)
|
||||||
@@ -279,7 +279,7 @@ export const Header = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ export const Footer = () => {
|
|||||||
as="footer"
|
as="footer"
|
||||||
py={4}
|
py={4}
|
||||||
textAlign="center"
|
textAlign="center"
|
||||||
position="fixed"
|
width="100%"
|
||||||
bottom="0"
|
flexShrink={0}
|
||||||
left="0"
|
fontSize="sm"
|
||||||
right="0"
|
|
||||||
>
|
>
|
||||||
Made with ❤️ by Theis Gaedigk - Class of 2019 at MCS-Bochum
|
Made with ❤️ by Theis Gaedigk - Class of 2019 at MCS-Bochum
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Button,
|
Button,
|
||||||
|
Container,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import MyAlert from "@/components/myChakra/MyAlert";
|
import MyAlert from "@/components/myChakra/MyAlert";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { API_BASE } from "@/config/api.config";
|
import { API_BASE } from "@/config/api.config";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Header } from "@/components/Header";
|
||||||
|
|
||||||
export const formatDateTime = (value: string | null | undefined) => {
|
export const formatDateTime = (value: string | null | undefined) => {
|
||||||
if (!value) return "N/A";
|
if (!value) return "N/A";
|
||||||
@@ -32,6 +33,7 @@ type Loan = {
|
|||||||
returned_date: string | null;
|
returned_date: string | null;
|
||||||
take_date: string | null;
|
take_date: string | null;
|
||||||
loaned_items_name: string[] | string;
|
loaned_items_name: string[] | string;
|
||||||
|
note: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Device = {
|
type Device = {
|
||||||
@@ -46,7 +48,6 @@ type Device = {
|
|||||||
|
|
||||||
const Landingpage: React.FC = () => {
|
const Landingpage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loans, setLoans] = useState<Loan[]>([]);
|
const [loans, setLoans] = useState<Loan[]>([]);
|
||||||
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
|
|||||||
const setError = (
|
const setError = (
|
||||||
status: "error" | "success",
|
status: "error" | "success",
|
||||||
message: string,
|
message: string,
|
||||||
description: string
|
description: string,
|
||||||
) => {
|
) => {
|
||||||
setIsError(false);
|
setIsError(false);
|
||||||
setErrorStatus(status);
|
setErrorStatus(status);
|
||||||
@@ -85,7 +86,7 @@ const Landingpage: React.FC = () => {
|
|||||||
setError(
|
setError(
|
||||||
"error",
|
"error",
|
||||||
t("error-by-loading"),
|
t("error-by-loading"),
|
||||||
t("unexpected-date-format_loan")
|
t("unexpected-date-format_loan"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ const Landingpage: React.FC = () => {
|
|||||||
setError(
|
setError(
|
||||||
"error",
|
"error",
|
||||||
t("error-by-loading"),
|
t("error-by-loading"),
|
||||||
t("unexpected-date-format_device")
|
t("unexpected-date-format_device"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -115,14 +116,8 @@ const Landingpage: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Container className="px-6 sm:px-8 pt-10">
|
||||||
<Heading as="h1" size="lg" mb={2}>
|
<Header />
|
||||||
Matthias-Claudius-Schule Technik
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/", { replace: true })}>
|
|
||||||
{t("back")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Heading as="h2" size="md" mb={4}>
|
<Heading as="h2" size="md" mb={4}>
|
||||||
{t("all-loans")}
|
{t("all-loans")}
|
||||||
@@ -168,6 +163,9 @@ const Landingpage: React.FC = () => {
|
|||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("return-date")}</strong>
|
<strong>{t("return-date")}</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>{t("note")}</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -184,6 +182,7 @@ const Landingpage: React.FC = () => {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
|
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
|
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
|
||||||
|
<Table.Cell>{loan.note}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
@@ -260,7 +259,7 @@ const Landingpage: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Cookies from "js-cookie";
|
|||||||
import { Navigate, useNavigate, useLocation } from "react-router-dom";
|
import { Navigate, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Footer } from "@/components/footer/Footer";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
import { API_BASE } from "@/config/api.config";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
@@ -71,7 +70,7 @@ export const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<Card.Root maxW="sm">
|
<Card.Root maxW="sm">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
@@ -115,7 +114,6 @@ export const LoginPage = () => {
|
|||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</form>
|
</form>
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { API_BASE } from "@/config/api.config";
|
|||||||
|
|
||||||
export const getBorrowableItems = async (
|
export const getBorrowableItems = async (
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string
|
endDate: string,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
|
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
|
||||||
@@ -22,7 +22,7 @@ export const getBorrowableItems = async (
|
|||||||
status: "error",
|
status: "error",
|
||||||
title: "Server error",
|
title: "Server error",
|
||||||
description:
|
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[],
|
itemIds: number[],
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
note: string | null
|
note: string | null,
|
||||||
) => {
|
) => {
|
||||||
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
|
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"take": "Abholen",
|
"take": "Abholen",
|
||||||
"return": "Zurückgeben",
|
"return": "Zurückgeben",
|
||||||
|
"serverError": "Serverfehler. Bitte versuchen Sie es später erneut, oder laden Sie die Seite neu.",
|
||||||
"take-loan-success": "Ausleihe erfolgreich abgeholt",
|
"take-loan-success": "Ausleihe erfolgreich abgeholt",
|
||||||
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
|
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
|
||||||
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
|
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"contactPage_messagePlaceholder": "Enter your message here...",
|
"contactPage_messagePlaceholder": "Enter your message here...",
|
||||||
"contactPage_messageErrorText": "This field cannot be empty.",
|
"contactPage_messageErrorText": "This field cannot be empty.",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"serverError": "Server error. Please try again later, or refresh the page.",
|
||||||
"take": "Take",
|
"take": "Take",
|
||||||
"return": "Return",
|
"return": "Return",
|
||||||
"take-loan-success": "Loan taken successfully",
|
"take-loan-success": "Loan taken successfully",
|
||||||
|
|||||||
73
LICENSE
Normal file
73
LICENSE
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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.
|
||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { loginFunc } from "@/utils/loginUser";
|
import { loginFunc } from "@/utils/loginUser";
|
||||||
import MyAlert from "../components/myChakra/MyAlert";
|
import MyAlert from "../components/myChakra/MyAlert";
|
||||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
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 Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
|||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>password</Field.Label>
|
<Field.Label>password</Field.Label>
|
||||||
<Input
|
<PasswordInput
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
159
admin/src/components/ui/password-input.tsx
Normal file
159
admin/src/components/ui/password-input.tsx
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ESNext",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
@@ -23,13 +24,10 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
/* Chakra / Pfad Aliases */
|
/* Path aliases */
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
}
|
||||||
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"backend-info": {
|
"backend-info": {
|
||||||
"version": "v2.1 (dev)"
|
"version": "v2.1.1 (dev)"
|
||||||
},
|
},
|
||||||
"frontend-info": {
|
"frontend-info": {
|
||||||
"version": "v2.1 (dev)"
|
"version": "v2.1.2 (dev)"
|
||||||
},
|
},
|
||||||
"admin-panel-info": {
|
"admin-panel-info": {
|
||||||
"version": "v1.3.2 (dev)"
|
"version": "v1.3.2 (dev)"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
|
|||||||
export const getLoanByCodeV2 = async (loan_code) => {
|
export const getLoanByCodeV2 = async (loan_code) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
|
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
|
||||||
[loan_code]
|
[loan_code],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { success: true, data: result[0] };
|
return { success: true, data: result[0] };
|
||||||
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
|
|||||||
export const changeInSafeStateV2 = async (itemId) => {
|
export const changeInSafeStateV2 = async (itemId) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
||||||
[itemId]
|
[itemId],
|
||||||
);
|
);
|
||||||
if (result.affectedRows > 0) {
|
if (result.affectedRows > 0) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -42,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const setReturnDateV2 = async (loanCode) => {
|
export const setReturnDateV2 = async (loanCode) => {
|
||||||
const [items] = await pool.query(
|
try {
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
const [items] = await pool.query(
|
||||||
[loanCode]
|
"SELECT loaned_items_id, username FROM loans WHERE loan_code = ?",
|
||||||
);
|
[loanCode],
|
||||||
|
);
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
if (items.length === 0)
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
return { success: false, message: "No items found for loan" };
|
||||||
[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 itemIds = Array.isArray(items[0].loaned_items_id)
|
const [result] = await pool.query(
|
||||||
? items[0].loaned_items_id
|
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
|
||||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
[loanCode],
|
||||||
|
);
|
||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
if (result.affectedRows === 0) return { success: false };
|
||||||
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
|
|
||||||
[owner[0].username, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
if (itemIds.length > 0) {
|
||||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
await pool.query(
|
||||||
[loanCode]
|
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = ? WHERE id IN (?)",
|
||||||
);
|
[items[0].username, itemIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
return { success: true, data: { returned: true } };
|
||||||
return { success: 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) => {
|
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(
|
const [items] = await pool.query(
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||||
[loanCode]
|
[loanCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
const [owner] = await pool.query(
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
"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)
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
? items[0].loaned_items_id
|
? items[0].loaned_items_id
|
||||||
@@ -93,18 +105,18 @@ export const setTakeDateV2 = async (loanCode) => {
|
|||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
const [setItemStates] = await pool.query(
|
||||||
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
|
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
|
||||||
[owner[0].username, itemIds]
|
[owner[0].username, itemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
"UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
|
||||||
[loanCode]
|
[loanCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return { success: false };
|
return { message: "Failed to set take date", success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllLoansV2 = async () => {
|
export const getAllLoansV2 = async () => {
|
||||||
@@ -118,12 +130,12 @@ export const getAllLoansV2 = async () => {
|
|||||||
export const openDoor = async (doorKey) => {
|
export const openDoor = async (doorKey) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
||||||
[doorKey]
|
[doorKey],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
const [changeItemSate] = await pool.query(
|
const [changeItemSate] = await pool.query(
|
||||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
||||||
[result[0].id]
|
[result[0].id],
|
||||||
);
|
);
|
||||||
if (changeItemSate.affectedRows > 0) {
|
if (changeItemSate.affectedRows > 0) {
|
||||||
return { success: true, data: result[0] };
|
return { success: true, data: result[0] };
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ router.get(
|
|||||||
} else {
|
} else {
|
||||||
res.status(404).json({ message: "Loan not found" });
|
res.status(404).json({ message: "Loan not found" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route for API to set the return date by the loan code
|
// 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 loanCode = req.params.loan_code;
|
||||||
const result = await setReturnDateV2(loanCode);
|
const result = await setReturnDateV2(loanCode);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
res.status(200).json({ data: result.data });
|
res.status(200).json({});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ message: "Failed to set return date" });
|
res.status(500).json({ message: "Failed to set return date" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route for API to set the take away date by the loan code
|
// 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 loanCode = req.params.loan_code;
|
||||||
const result = await setTakeDateV2(loanCode);
|
const result = await setTakeDateV2(loanCode);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
res.status(200).json({ data: result.data });
|
res.status(200).json({});
|
||||||
} else {
|
} 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
|
// Route for API to open a door
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
|
|||||||
mailInfo.data.start_date,
|
mailInfo.data.start_date,
|
||||||
mailInfo.data.end_date,
|
mailInfo.data.end_date,
|
||||||
mailInfo.data.created_at,
|
mailInfo.data.created_at,
|
||||||
|
mailInfo.data.note,
|
||||||
);
|
);
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Loan created successfully",
|
message: "Loan created successfully",
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ const formatDateTime = (value) => {
|
|||||||
return "N/A";
|
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 brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||||
const itemsList =
|
const itemsList =
|
||||||
Array.isArray(items) && items.length
|
Array.isArray(items) && items.length
|
||||||
@@ -116,6 +123,12 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
|||||||
createdDate,
|
createdDate,
|
||||||
)}</td>
|
)}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin:22px 0 0 0; font-size:14px;">
|
<p style="margin:22px 0 0 0; font-size:14px;">
|
||||||
@@ -134,7 +147,14 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
function buildLoanEmailText({
|
||||||
|
user,
|
||||||
|
items,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
createdDate,
|
||||||
|
note,
|
||||||
|
}) {
|
||||||
const itemsText =
|
const itemsText =
|
||||||
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
||||||
return [
|
return [
|
||||||
@@ -145,10 +165,18 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
|||||||
`Start: ${formatDateTime(startDate)}`,
|
`Start: ${formatDateTime(startDate)}`,
|
||||||
`Ende: ${formatDateTime(endDate)}`,
|
`Ende: ${formatDateTime(endDate)}`,
|
||||||
`Erstellt am: ${formatDateTime(createdDate)}`,
|
`Erstellt am: ${formatDateTime(createdDate)}`,
|
||||||
|
`Notiz: ${note || "Keine Notiz"}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
export function sendMailLoan(
|
||||||
|
user,
|
||||||
|
items,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
createdDate,
|
||||||
|
note,
|
||||||
|
) {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.MAIL_HOST,
|
host: process.env.MAIL_HOST,
|
||||||
port: process.env.MAIL_PORT,
|
port: process.env.MAIL_PORT,
|
||||||
@@ -170,8 +198,16 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
createdDate,
|
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("Loan message sent:", info.messageId);
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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 1–6, 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');
|
|
||||||
@@ -42,6 +42,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3310:3306"
|
- "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:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
mysql-v2-data:
|
mysql-v2-data:
|
||||||
|
|||||||
Reference in New Issue
Block a user