Compare commits
41 Commits
debian12
...
893a7e041d
| Author | SHA1 | Date | |
|---|---|---|---|
| 893a7e041d | |||
| d4b2e8db20 | |||
| b52d707bf5 | |||
| b6ebfcd631 | |||
| 7ecd9dad3f | |||
| 7f9ed23a86 | |||
| 21b152ef2b | |||
| 451a5a92dd | |||
| 85b519c5b1 | |||
| 27db4c7390 | |||
| 1b08344a0f | |||
| a0be100db5 | |||
| 2143d53eb5 | |||
| c4d5ebd9ae | |||
| 938e9000f8 | |||
| 558a8330af | |||
| ad2395f98b | |||
| 51baf8d970 | |||
| d18465ff1d | |||
| b36f1ba9ba | |||
| 784bd1e8ce | |||
| ae7aec8d3b | |||
| 3f9381a80c | |||
| 1826086186 | |||
| af4abfc8f9 | |||
| ba0f06e104 | |||
| a932144e94 | |||
| 36ad60b782 | |||
| e4467dba32 | |||
| 410923af92 | |||
| 24c405386b | |||
| d5296bd3fa | |||
| 3ee2f6b670 | |||
| 09af4c760c | |||
| 3fd0fd9584 | |||
| 27984ebac8 | |||
| 3d4aab74d5 | |||
| 4076630eec | |||
| 6025212e93 | |||
| de554048eb | |||
| e1d79d2c79 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -109,11 +109,8 @@ backend/public/uploads/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# API keys and secrets (additional protection)
|
# API keys and secrets (additional protection)
|
||||||
|
config/
|
||||||
secrets/
|
secrets/
|
||||||
keys/
|
keys/
|
||||||
|
|
||||||
ToDo.txt
|
ToDo.txt
|
||||||
|
|
||||||
|
|
||||||
# only in development branch
|
|
||||||
next-env.d.ts
|
|
||||||
22
Docs/CHANGELOG.md
Normal file
22
Docs/CHANGELOG.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Changelog
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
## Current hosted version
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
> No changelog available.
|
||||||
|
|
||||||
|
## Upcoming changes
|
||||||
|
|
||||||
|
v1.2
|
||||||
|
|
||||||
|
### Fixes and improvements
|
||||||
|
|
||||||
|
- Implement user roles and permissions
|
||||||
|
- Improve form validation and error handling
|
||||||
|
- Add loading indicators for async actions
|
||||||
|
- Optimize performance for large datasets
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- Admin panel for managing users, permissions and all of the system settings and database
|
||||||
@@ -3,5 +3,3 @@
|
|||||||
This document provides an overview of the backend API endpoints and their usage.
|
This document provides an overview of the backend API endpoints and their usage.
|
||||||
|
|
||||||
To get to that information, go to the `backend_API_docs` directory.
|
To get to that information, go to the `backend_API_docs` directory.
|
||||||
|
|
||||||
If you need help, see HELP.md file in this directory.
|
|
||||||
@@ -1,83 +1,58 @@
|
|||||||
# Borrow System API Documentation
|
# Backend API docs (apiV2)
|
||||||
|
|
||||||
**Frontend:** https://insta.the1s.de
|
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
|
||||||
**Backend base URL:** `https://insta.the1s.de/backend/api`
|
|
||||||
|
On this page you will learn how my API works.
|
||||||
|
|
||||||
|
## General information
|
||||||
|
|
||||||
|
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` which is for my web frontend, because this file works together with my JWT token service.
|
||||||
|
|
||||||
|
But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
|
||||||
|
|
||||||
|
But first you have to get an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
- Frontend: `https://insta.the1s.de`
|
||||||
|
- Backend: `https://backend.insta.the1s.de`
|
||||||
|
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
|
||||||
|
|
||||||
|
You can see the status of this and all my other services at `https://status.the1s.de`.
|
||||||
|
|
||||||
|
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All API endpoints require **either**:
|
All endpoints require an API key as a path parameter named `:key`.
|
||||||
|
|
||||||
### 1. Bearer Token (JWT)
|
Example: `/apiV2/items/:key`
|
||||||
|
|
||||||
Send an `Authorization` header:
|
If the key is missing or invalid, the API responds with `401 Unauthorized`.
|
||||||
|
|
||||||
```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
|
### 1) Get all items
|
||||||
|
|
||||||
**GET** `/api/items/:key`
|
GET `/apiV2/items/:key`
|
||||||
|
|
||||||
Returns a list of all items.
|
Returns a list of all items wrapped in a `data` object.
|
||||||
|
|
||||||
#### Path Parameters
|
Example request:
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
```
|
||||||
|
GET https://backend.insta.the1s.de/apiV2/items/12345
|
||||||
#### 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)
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
@@ -85,282 +60,151 @@ Authorization: Bearer <JWT_TOKEN>
|
|||||||
"item_name": "DJI 1er Mikro",
|
"item_name": "DJI 1er Mikro",
|
||||||
"can_borrow_role": 4,
|
"can_borrow_role": 4,
|
||||||
"inSafe": 1,
|
"inSafe": 1,
|
||||||
"safe_nr": 3,
|
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||||
"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",
|
|
||||||
"currently_borrowing": null
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
Fields:
|
||||||
|
|
||||||
```json
|
- `id`: Unique identifier
|
||||||
{
|
- `item_name`: Item name
|
||||||
"message": "Failed to fetch items"
|
- `can_borrow_role`: Role allowed to borrow
|
||||||
}
|
- `inSafe`: 1 if in locker, 0 otherwise
|
||||||
```
|
- `entry_created_at`: Creation timestamp
|
||||||
|
|
||||||
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Toggle Item Safe State
|
### 2) Change item safe state
|
||||||
|
|
||||||
Toggles `in_safe` between `0` and `1` for a given item.
|
POST `/apiV2/controlInSafe/:key/:itemId/:state`
|
||||||
|
|
||||||
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
|
Updates `inSafe` (locker) state of an item.
|
||||||
|
|
||||||
**POST** `/api/change-state/:key/:itemId`
|
- `state` must be `"1"` (in safe) or `"0"` (not in safe)
|
||||||
|
|
||||||
#### Path Parameters
|
Example request:
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
```
|
||||||
- `:itemId` – Item ID (integer)
|
POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
|
||||||
|
|
||||||
#### 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)
|
Example response (shape depends on database service):
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{ "data": { /* update result */ } }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
|
Status:
|
||||||
|
|
||||||
#### Error Response (500)
|
- 200 on success
|
||||||
|
- 400 if `state` is invalid
|
||||||
|
- 500 on failure
|
||||||
|
|
||||||
```json
|
**You can get the item id on the admin panel, from your system administrator.**
|
||||||
{
|
|
||||||
"message": "Failed to update item state"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Get Loan by Code
|
### 3) Get loan by code
|
||||||
|
|
||||||
Fetch loan information by `loan_code`.
|
GET `/apiV2/getLoanByCode/:key/:loan_code`
|
||||||
|
|
||||||
**GET** `/api/get-loan-by-code/:key/:loan_code`
|
Retrieves the details of a specific loan.
|
||||||
|
|
||||||
#### Path Parameters
|
Example request:
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
```
|
||||||
- `:loan_code` – Loan code (string)
|
GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
|
||||||
|
|
||||||
#### 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)
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"username": "john",
|
"id": 6,
|
||||||
|
"username": "theis",
|
||||||
|
"loan_code": 646473,
|
||||||
|
"start_date": "2025-08-25T13:23:00.000Z",
|
||||||
|
"end_date": "2025-08-26T13:23:00.000Z",
|
||||||
|
"take_date": null,
|
||||||
"returned_date": null,
|
"returned_date": null,
|
||||||
"take_date": "2025-01-01T10:00:00.000Z",
|
"created_at": "2025-08-20T11:23:40.000Z",
|
||||||
"lockers": "[1, 2, 3]"
|
"loaned_items_id": [8, 9],
|
||||||
|
"loaned_items_name": ["SD Karten", "Kameragimbal"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (404)
|
Status:
|
||||||
|
|
||||||
```json
|
- 200 on success
|
||||||
{
|
- 404 if not found
|
||||||
"message": "Loan not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Set Loan Return Date
|
### 4) Set return date (now) by loan code
|
||||||
|
|
||||||
Sets `returned_date = NOW()` on a loan and updates related items:
|
POST `/apiV2/setReturnDate/:key/:loan_code`
|
||||||
|
|
||||||
- `in_safe = 1`
|
Sets the `returned_date` to the current server time.
|
||||||
- `currently_borrowing = NULL`
|
|
||||||
- `last_borrowed_person = username`
|
|
||||||
|
|
||||||
**POST** `/api/set-return-date/:key/:loan_code`
|
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||||
|
|
||||||
#### Path Parameters
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
Example request:
|
||||||
- `:loan_code` – Loan code (string)
|
|
||||||
|
|
||||||
#### Authentication
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
|
||||||
- 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)
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{ "data": { /* update result */ } }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Failed to set return date"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Set Loan Take Date
|
### 5) Set take date (now) by loan code
|
||||||
|
|
||||||
Sets `take_date = NOW()` on a loan and updates related items:
|
POST `/apiV2/setTakeDate/:key/:loan_code`
|
||||||
|
|
||||||
- `in_safe = 0`
|
Sets the `take_date` to the current server time.
|
||||||
- `currently_borrowing = username`
|
|
||||||
|
|
||||||
**POST** `/api/set-take-date/:key/:loan_code`
|
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||||
|
|
||||||
#### Path Parameters
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
Example request:
|
||||||
- `:loan_code` – Loan code (string)
|
|
||||||
|
|
||||||
#### Authentication
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
|
||||||
- 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)
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{ "data": { /* update result */ } }
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error Response (500)
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Failed to set take date"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. Open Door by Door Key
|
## Error handling
|
||||||
|
|
||||||
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
|
- 401 Unauthorized: Missing or invalid API key
|
||||||
|
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
|
||||||
**GET** `/api/open-door/:key/:doorKey`
|
- 404 Not Found: Loan not found
|
||||||
|
- 500 Internal Server Error: Database or server error
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
- `:key` – API key (8-digit number)
|
|
||||||
- `:doorKey` – Door key/token (string) used by hardware to identify the locker.
|
|
||||||
|
|
||||||
#### 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)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"safe_nr": 5,
|
|
||||||
"id": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Error Response (500)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Failed to open door"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication Error Messages
|
If you have questions or want to collaborate, please reach out!
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
|
||||||
COPY --from=builder /app/dist .
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /backend {
|
|
||||||
return 301 /backend/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /backend/ {
|
|
||||||
proxy_pass http://borrow_system-backend_v2:8102/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
|
||||||
expires 1y;
|
|
||||||
access_log off;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
|
||||||
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
|
||||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Display für größere Überschriften */
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
|
|
||||||
format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text für Fließtext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global anwenden mit Fallbacks */
|
|
||||||
:root {
|
|
||||||
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
|
|
||||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import "./App.css";
|
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
|
||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|
||||||
import { HomePage } from "@/pages/HomePage";
|
|
||||||
import { ProtectedRoutes } from "./utils/ProtectedRoutes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { setIsLoggedInAtom } from "@/states/Atoms";
|
|
||||||
import { UserContext, type User } from "./states/Context";
|
|
||||||
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 { Footer } from "./components/footer/Footer";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [user, setUser] = useState<User | undefined>(undefined);
|
|
||||||
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
|
||||||
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Cookies.get("token")) {
|
|
||||||
const verifyToken = async () => {
|
|
||||||
const response = await fetch(`${API_BASE}/verify`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
setTriggerLogout(false);
|
|
||||||
const data = await response.json();
|
|
||||||
setUser({
|
|
||||||
username: data.user.username,
|
|
||||||
is_admin: data.user.is_admin,
|
|
||||||
first_name: data.user.first_name,
|
|
||||||
last_name: data.user.last_name,
|
|
||||||
role: data.user.role,
|
|
||||||
});
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
} else {
|
|
||||||
Cookies.remove("token");
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
verifyToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// set initial language
|
|
||||||
if (!Cookies.get("language")) {
|
|
||||||
const getBrowserLanguage = () => {
|
|
||||||
const lang = navigator.languages?.[0] || navigator.language || "en";
|
|
||||||
return lang.split("-")[0].toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
changeLanguage(getBrowserLanguage());
|
|
||||||
Cookies.set("language", getBrowserLanguage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Cookies.get("language")) {
|
|
||||||
changeLanguage(Cookies.get("language") || "en");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Flex direction="column" minH="100vh">
|
|
||||||
<Box as="main" flex="1">
|
|
||||||
<UserContext.Provider value={user}>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route element={<ProtectedRoutes />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/my-loans" element={<MyLoansPage />} />
|
|
||||||
<Route path="/landingpage" element={<Landingpage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</UserContext.Provider>
|
|
||||||
</Box>
|
|
||||||
<Footer />
|
|
||||||
</Flex>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
Heading,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
HStack,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
Box,
|
|
||||||
Avatar,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
CircleUserRound,
|
|
||||||
Code,
|
|
||||||
LifeBuoy,
|
|
||||||
LogOut,
|
|
||||||
CalendarPlus,
|
|
||||||
MoreVertical,
|
|
||||||
Languages,
|
|
||||||
Table,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useUserContext } from "@/states/Context";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { UserDialogue } from "./UserDialogue";
|
|
||||||
|
|
||||||
export const Header = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const userData = useUserContext();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
|
||||||
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
|
||||||
|
|
||||||
const [userDialog, setUserDialog] = useState(false);
|
|
||||||
|
|
||||||
const username = userData.first_name ? userData.first_name : "N/A";
|
|
||||||
const fullname = userData.first_name + " " + userData.last_name;
|
|
||||||
const randomColor = [
|
|
||||||
"gray",
|
|
||||||
"red",
|
|
||||||
"orange",
|
|
||||||
"yellow",
|
|
||||||
"green",
|
|
||||||
"teal",
|
|
||||||
"blue",
|
|
||||||
"cyan",
|
|
||||||
"purple",
|
|
||||||
"pink",
|
|
||||||
];
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
Cookies.remove("token");
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
setTriggerLogout(true);
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
as="header"
|
|
||||||
gap={3}
|
|
||||||
className="mb-6"
|
|
||||||
position="relative"
|
|
||||||
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
|
|
||||||
>
|
|
||||||
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
|
|
||||||
<Box
|
|
||||||
display={{ base: "block", md: "none" }}
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
right="0"
|
|
||||||
transform="translateY(-50%)"
|
|
||||||
zIndex={2}
|
|
||||||
>
|
|
||||||
<Menu.Root>
|
|
||||||
<Menu.Trigger asChild>
|
|
||||||
<IconButton
|
|
||||||
aria-label="Aktionen"
|
|
||||||
variant="solid"
|
|
||||||
colorScheme="teal"
|
|
||||||
size="md"
|
|
||||||
borderRadius="full"
|
|
||||||
boxShadow="md"
|
|
||||||
>
|
|
||||||
<MoreVertical size={20} />
|
|
||||||
</IconButton>
|
|
||||||
</Menu.Trigger>
|
|
||||||
<Menu.Positioner>
|
|
||||||
<Menu.Content>
|
|
||||||
<Menu.Item
|
|
||||||
value="create-loan"
|
|
||||||
onSelect={() => navigate("/", { replace: true })}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<CalendarPlus size={16} />
|
|
||||||
<Text as="span">{t("create-loan")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
value="my-loans"
|
|
||||||
onSelect={() => navigate("/my-loans", { replace: true })}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<CircleUserRound size={16} />
|
|
||||||
<Text as="span">{t("my-loans")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
value="landingpage"
|
|
||||||
onSelect={() => navigate("/landingpage", { replace: true })}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<Table size={16} />
|
|
||||||
<Text as="span">{t("landingpage")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
value="change-language"
|
|
||||||
onSelect={() => {
|
|
||||||
const currentLang = Cookies.get("language") || "en";
|
|
||||||
const newLang = currentLang === "en" ? "de" : "en";
|
|
||||||
Cookies.set("language", newLang);
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<Languages size={16} />
|
|
||||||
<Text as="span">{t("change-language")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
value="help"
|
|
||||||
onSelect={() =>
|
|
||||||
window.open(
|
|
||||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<LifeBuoy size={16} />
|
|
||||||
<Text as="span">{t("help")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
value="source-code"
|
|
||||||
onSelect={() =>
|
|
||||||
window.open(
|
|
||||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
children={
|
|
||||||
<HStack gap={3}>
|
|
||||||
<Code size={16} />
|
|
||||||
<Text as="span">{t("source-code")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Menu.Separator />
|
|
||||||
<Menu.Item
|
|
||||||
value="logout"
|
|
||||||
onSelect={logout}
|
|
||||||
children={
|
|
||||||
<HStack gap={3} color="red.500">
|
|
||||||
<LogOut size={16} />
|
|
||||||
<Text as="span">{t("logout")}</Text>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Menu.Content>
|
|
||||||
</Menu.Positioner>
|
|
||||||
</Menu.Root>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Flex
|
|
||||||
direction={{ base: "column", md: "row" }}
|
|
||||||
align={{ base: "stretch", md: "center" }}
|
|
||||||
justify="space-between"
|
|
||||||
gap={4}
|
|
||||||
>
|
|
||||||
{/* Left: Title + user info */}
|
|
||||||
<Stack gap={1}>
|
|
||||||
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
|
|
||||||
<Flex align="center" justify="space-between" gap={2}>
|
|
||||||
<Heading
|
|
||||||
size="2xl"
|
|
||||||
className="tracking-tight text-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{t("app-title")}
|
|
||||||
</Heading>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<HStack gap={3} align="center" flexWrap="wrap">
|
|
||||||
<Text fontSize="md" className="text-slate-600 dark:text-slate-400">
|
|
||||||
{t("greeting")}
|
|
||||||
<strong>{username}</strong>!
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Avatar: visible on mobile, hidden on desktop (desktop version is in the actions bar) */}
|
|
||||||
<HStack display={{ base: "flex", md: "none" }}>
|
|
||||||
<Avatar.Root>
|
|
||||||
<button
|
|
||||||
onClick={() => setUserDialog(true)}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Avatar.Fallback name={fullname} />
|
|
||||||
</button>
|
|
||||||
</Avatar.Root>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* Right: Actions */}
|
|
||||||
{/* Desktop actions */}
|
|
||||||
<HStack
|
|
||||||
gap={2}
|
|
||||||
align="center"
|
|
||||||
justify="flex-end"
|
|
||||||
flexWrap="wrap"
|
|
||||||
display={{ base: "none", md: "flex" }}
|
|
||||||
>
|
|
||||||
{/* Desktop avatar, aligned with action buttons */}
|
|
||||||
<Avatar.Root
|
|
||||||
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setUserDialog(true)}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Avatar.Fallback name={fullname} />
|
|
||||||
</button>
|
|
||||||
</Avatar.Root>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
colorScheme="teal"
|
|
||||||
onClick={() => navigate("/", { replace: true })}
|
|
||||||
>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<CalendarPlus size={18} />
|
|
||||||
<Text as="span">{t("create-loan")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/my-loans", { replace: true })}>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<CircleUserRound size={18} />
|
|
||||||
<Text as="span">{t("my-loans")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={() => navigate("/landingpage", { replace: true })}>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<Table size={18} />
|
|
||||||
<Text as="span">{t("landingpage")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
const currentLang = Cookies.get("language") || "en";
|
|
||||||
const newLang = currentLang === "en" ? "de" : "en";
|
|
||||||
Cookies.set("language", newLang);
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<Languages size={18} />
|
|
||||||
<Text as="span">{t("change-language")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button variant="ghost">
|
|
||||||
<HStack gap={2}>
|
|
||||||
<LifeBuoy size={18} />
|
|
||||||
<Text as="span">{t("help")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Button variant="ghost">
|
|
||||||
<HStack gap={2}>
|
|
||||||
<Code size={18} />
|
|
||||||
<Text as="span">{t("source-code")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<Button onClick={logout} variant="outline" colorScheme="red">
|
|
||||||
<HStack gap={2}>
|
|
||||||
<LogOut size={18} />
|
|
||||||
<Text as="span">{t("logout")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* User Info Dialoge */}
|
|
||||||
{userDialog && <UserDialogue setUserDialog={setUserDialog} fullname={fullname} randomColor={randomColor} />}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
CloseButton,
|
|
||||||
Dialog,
|
|
||||||
Portal,
|
|
||||||
HStack,
|
|
||||||
Box,
|
|
||||||
Avatar,
|
|
||||||
Card,
|
|
||||||
Grid,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
|
||||||
import { RotateCcwKey } from "lucide-react";
|
|
||||||
import MyAlert from "./myChakra/MyAlert";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
import { useUserContext } from "@/states/Context";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
type UserDialogueProps = {
|
|
||||||
setUserDialog: (value: boolean) => void;
|
|
||||||
fullname: string;
|
|
||||||
randomColor: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserDialogue = (props: UserDialogueProps) => {
|
|
||||||
const userData = useUserContext();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Error handling states
|
|
||||||
const [isMsg, setIsMsg] = useState(false);
|
|
||||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
|
||||||
const [msgTitle, setMsgTitle] = useState("");
|
|
||||||
const [msgDescription, setMsgDescription] = useState("");
|
|
||||||
|
|
||||||
const [oldPassword, setOldPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
|
|
||||||
// Dialog control
|
|
||||||
const [isPwOpen, setPwOpen] = useState(false);
|
|
||||||
|
|
||||||
const changePassword = async () => {
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setMsgTitle(t("err_pw_change"));
|
|
||||||
setMsgDescription(t("pw_mismatch"));
|
|
||||||
setMsgStatus("error");
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/users/change-password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ oldPassword, newPassword }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setMsgTitle(t("err_pw_change"));
|
|
||||||
setMsgDescription(t("pw_mismatch"));
|
|
||||||
setMsgStatus("error");
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMsgTitle(t("pw_success"));
|
|
||||||
setMsgDescription(t("pw_success_desc"));
|
|
||||||
setMsgStatus("success");
|
|
||||||
setIsMsg(true);
|
|
||||||
|
|
||||||
setOldPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
position="fixed"
|
|
||||||
inset={0}
|
|
||||||
zIndex={1000}
|
|
||||||
align="center"
|
|
||||||
justify="center"
|
|
||||||
bg="blackAlpha.400"
|
|
||||||
backdropFilter="blur(6px)"
|
|
||||||
>
|
|
||||||
<Card.Root maxW="sm" w="full" mx={4}>
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title>
|
|
||||||
<Flex justify="center" align="center" w="100%">
|
|
||||||
<Avatar.Root
|
|
||||||
size={"2xl"}
|
|
||||||
colorPalette={props.randomColor[Math.floor(Math.random() * 10)]}
|
|
||||||
>
|
|
||||||
<Avatar.Fallback name={props.fullname} />
|
|
||||||
</Avatar.Root>
|
|
||||||
</Flex>
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Description>{t("user-info-desc")}</Card.Description>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body>
|
|
||||||
<Stack gap="4" w="full">
|
|
||||||
<Box as="dl">
|
|
||||||
<Grid
|
|
||||||
templateColumns="auto 1fr"
|
|
||||||
rowGap={2}
|
|
||||||
columnGap={4}
|
|
||||||
alignItems="start"
|
|
||||||
>
|
|
||||||
<Text as="dt" fontWeight="bold" textAlign="left">
|
|
||||||
{t("first-name")}:
|
|
||||||
</Text>
|
|
||||||
<Text as="dd">{userData.first_name}</Text>
|
|
||||||
|
|
||||||
<Text as="dt" fontWeight="bold" textAlign="left">
|
|
||||||
{t("last-name")}:
|
|
||||||
</Text>
|
|
||||||
<Text as="dd">{userData.last_name}</Text>
|
|
||||||
|
|
||||||
<Text as="dt" fontWeight="bold" textAlign="left">
|
|
||||||
{t("username")}:
|
|
||||||
</Text>
|
|
||||||
<Text as="dd">{userData.username}</Text>
|
|
||||||
|
|
||||||
<Text as="dt" fontWeight="bold" textAlign="left">
|
|
||||||
{t("role")}:
|
|
||||||
</Text>
|
|
||||||
<Text as="dd">{userData.role}</Text>
|
|
||||||
|
|
||||||
<Text as="dt" fontWeight="bold" textAlign="left">
|
|
||||||
{t("admin-status")}:
|
|
||||||
</Text>
|
|
||||||
<Text as="dd">{userData.is_admin ? t("yes") : t("no")}</Text>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button variant="solid" onClick={() => setPwOpen(true)}>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<RotateCcwKey size={18} />
|
|
||||||
<Text as="span">{t("change-password")}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card.Body>
|
|
||||||
<Card.Footer justifyContent="flex-end">
|
|
||||||
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</Card.Footer>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
{/* Passwort-Dialog (kontrolliert) */}
|
|
||||||
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
|
|
||||||
<Portal>
|
|
||||||
<Dialog.Backdrop />
|
|
||||||
<Dialog.Positioner>
|
|
||||||
<Dialog.Content maxW="md">
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>{t("change-password")}</Dialog.Title>
|
|
||||||
</Dialog.Header>
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
changePassword();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Body>
|
|
||||||
<Stack gap={3}>
|
|
||||||
<PasswordInput
|
|
||||||
value={oldPassword}
|
|
||||||
onChange={(e) => setOldPassword(e.target.value)}
|
|
||||||
placeholder={t("old-password")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder={t("new-password")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder={t("confirm-password")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Dialog.Body>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Stack w="100%" gap={3}>
|
|
||||||
{isMsg && (
|
|
||||||
<MyAlert
|
|
||||||
status={msgStatus}
|
|
||||||
title={msgTitle}
|
|
||||||
description={msgDescription}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<HStack justify="flex-end" gap={2}>
|
|
||||||
<Dialog.ActionTrigger asChild>
|
|
||||||
<Button variant="outline">{t("cancel")}</Button>
|
|
||||||
</Dialog.ActionTrigger>
|
|
||||||
<Button type="submit" colorScheme="teal">
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</form>
|
|
||||||
<Dialog.CloseTrigger asChild>
|
|
||||||
<CloseButton size="sm" />
|
|
||||||
</Dialog.CloseTrigger>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Positioner>
|
|
||||||
</Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Box } from "@chakra-ui/react";
|
|
||||||
import { useVersionInfoQuery } from "./versionInfo.query";
|
|
||||||
|
|
||||||
export const Footer = () => {
|
|
||||||
const { data: info } = useVersionInfoQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as="footer"
|
|
||||||
py={4}
|
|
||||||
textAlign="center"
|
|
||||||
position="fixed"
|
|
||||||
bottom="0"
|
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
>
|
|
||||||
Made with ❤️ by Theis Gaedigk - Class of 2019 at MCS-Bochum
|
|
||||||
<br />
|
|
||||||
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
|
|
||||||
Backend-Version: {info ? info["backend-info"].version : "N/A"}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
export const useVersionInfoQuery = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["versionInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${API_BASE}/`, {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"Failed to fetch version info (versionInfo.query.ts): ",
|
|
||||||
response.statusText
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
"backend-info": {
|
|
||||||
version: "N/A",
|
|
||||||
},
|
|
||||||
"frontend-info": {
|
|
||||||
version: "N/A",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Alert } from "@chakra-ui/react";
|
|
||||||
|
|
||||||
type MyAlertProps = {
|
|
||||||
status: "error" | "success";
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyAlert: React.FC<MyAlertProps> = ({ title, description, status }) => {
|
|
||||||
return (
|
|
||||||
<Alert.Root status={status}>
|
|
||||||
<Alert.Indicator />
|
|
||||||
<Alert.Content>
|
|
||||||
<Alert.Title>{title}</Alert.Title>
|
|
||||||
<Alert.Description>{description}</Alert.Description>
|
|
||||||
</Alert.Content>
|
|
||||||
</Alert.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MyAlert;
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
|
|
||||||
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
|
|
||||||
import { ThemeProvider, useTheme } from "next-themes"
|
|
||||||
import type { ThemeProviderProps } from "next-themes"
|
|
||||||
import * as React from "react"
|
|
||||||
import { LuMoon, LuSun } from "react-icons/lu"
|
|
||||||
|
|
||||||
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
|
||||||
|
|
||||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ColorMode = "light" | "dark"
|
|
||||||
|
|
||||||
export interface UseColorModeReturn {
|
|
||||||
colorMode: ColorMode
|
|
||||||
setColorMode: (colorMode: ColorMode) => void
|
|
||||||
toggleColorMode: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useColorMode(): UseColorModeReturn {
|
|
||||||
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
|
|
||||||
const colorMode = forcedTheme || resolvedTheme
|
|
||||||
const toggleColorMode = () => {
|
|
||||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
colorMode: colorMode as ColorMode,
|
|
||||||
setColorMode: setTheme,
|
|
||||||
toggleColorMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useColorModeValue<T>(light: T, dark: T) {
|
|
||||||
const { colorMode } = useColorMode()
|
|
||||||
return colorMode === "dark" ? dark : light
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorModeIcon() {
|
|
||||||
const { colorMode } = useColorMode()
|
|
||||||
return colorMode === "dark" ? <LuMoon /> : <LuSun />
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
|
|
||||||
|
|
||||||
export const ColorModeButton = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
ColorModeButtonProps
|
|
||||||
>(function ColorModeButton(props, ref) {
|
|
||||||
const { toggleColorMode } = useColorMode()
|
|
||||||
return (
|
|
||||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
|
||||||
<IconButton
|
|
||||||
onClick={toggleColorMode}
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Toggle color mode"
|
|
||||||
size="sm"
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
css={{
|
|
||||||
_icon: {
|
|
||||||
width: "5",
|
|
||||||
height: "5",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColorModeIcon />
|
|
||||||
</IconButton>
|
|
||||||
</ClientOnly>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
|
||||||
function LightMode(props, ref) {
|
|
||||||
return (
|
|
||||||
<Span
|
|
||||||
color="fg"
|
|
||||||
display="contents"
|
|
||||||
className="chakra-theme light"
|
|
||||||
colorPalette="gray"
|
|
||||||
colorScheme="light"
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
|
||||||
function DarkMode(props, ref) {
|
|
||||||
return (
|
|
||||||
<Span
|
|
||||||
color="fg"
|
|
||||||
display="contents"
|
|
||||||
className="chakra-theme dark"
|
|
||||||
colorPalette="gray"
|
|
||||||
colorScheme="dark"
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ButtonProps,
|
|
||||||
GroupProps,
|
|
||||||
InputProps,
|
|
||||||
StackProps,
|
|
||||||
} from "@chakra-ui/react"
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
HStack,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
Stack,
|
|
||||||
mergeRefs,
|
|
||||||
useControllableState,
|
|
||||||
} from "@chakra-ui/react"
|
|
||||||
import * as React from "react"
|
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu"
|
|
||||||
|
|
||||||
export interface PasswordVisibilityProps {
|
|
||||||
/**
|
|
||||||
* The default visibility state of the password input.
|
|
||||||
*/
|
|
||||||
defaultVisible?: boolean
|
|
||||||
/**
|
|
||||||
* The controlled visibility state of the password input.
|
|
||||||
*/
|
|
||||||
visible?: boolean
|
|
||||||
/**
|
|
||||||
* Callback invoked when the visibility state changes.
|
|
||||||
*/
|
|
||||||
onVisibleChange?: (visible: boolean) => void
|
|
||||||
/**
|
|
||||||
* Custom icons for the visibility toggle button.
|
|
||||||
*/
|
|
||||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordInputProps
|
|
||||||
extends InputProps,
|
|
||||||
PasswordVisibilityProps {
|
|
||||||
rootProps?: GroupProps
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PasswordInput = React.forwardRef<
|
|
||||||
HTMLInputElement,
|
|
||||||
PasswordInputProps
|
|
||||||
>(function PasswordInput(props, ref) {
|
|
||||||
const {
|
|
||||||
rootProps,
|
|
||||||
defaultVisible,
|
|
||||||
visible: visibleProp,
|
|
||||||
onVisibleChange,
|
|
||||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
|
||||||
...rest
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const [visible, setVisible] = useControllableState({
|
|
||||||
value: visibleProp,
|
|
||||||
defaultValue: defaultVisible || false,
|
|
||||||
onChange: onVisibleChange,
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputGroup
|
|
||||||
endElement={
|
|
||||||
<VisibilityTrigger
|
|
||||||
disabled={rest.disabled}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
if (rest.disabled) return
|
|
||||||
if (e.button !== 0) return
|
|
||||||
e.preventDefault()
|
|
||||||
setVisible(!visible)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
|
||||||
</VisibilityTrigger>
|
|
||||||
}
|
|
||||||
{...rootProps}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
{...rest}
|
|
||||||
ref={mergeRefs(ref, inputRef)}
|
|
||||||
type={visible ? "text" : "password"}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
function VisibilityTrigger(props, ref) {
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
ref={ref}
|
|
||||||
me="-2"
|
|
||||||
aspectRatio="square"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
height="calc(100% - {spacing.2})"
|
|
||||||
aria-label="Toggle password visibility"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface PasswordStrengthMeterProps extends StackProps {
|
|
||||||
max?: number
|
|
||||||
value: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PasswordStrengthMeter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
PasswordStrengthMeterProps
|
|
||||||
>(function PasswordStrengthMeter(props, ref) {
|
|
||||||
const { max = 4, value, ...rest } = props
|
|
||||||
|
|
||||||
const percent = (value / max) * 100
|
|
||||||
const { label, colorPalette } = getColorPalette(percent)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
|
||||||
<HStack width="full" {...rest}>
|
|
||||||
{Array.from({ length: max }).map((_, index) => (
|
|
||||||
<Box
|
|
||||||
key={index}
|
|
||||||
height="1"
|
|
||||||
flex="1"
|
|
||||||
rounded="sm"
|
|
||||||
data-selected={index < value ? "" : undefined}
|
|
||||||
layerStyle="fill.subtle"
|
|
||||||
colorPalette="gray"
|
|
||||||
_selected={{
|
|
||||||
colorPalette,
|
|
||||||
layerStyle: "fill.solid",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function getColorPalette(percent: number) {
|
|
||||||
switch (true) {
|
|
||||||
case percent < 33:
|
|
||||||
return { label: "Low", colorPalette: "red" }
|
|
||||||
case percent < 66:
|
|
||||||
return { label: "Medium", colorPalette: "orange" }
|
|
||||||
default:
|
|
||||||
return { label: "High", colorPalette: "green" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
|
|
||||||
|
|
||||||
export interface ColorModeProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorModeProvider({ children }: ColorModeProviderProps) {
|
|
||||||
// Wrap children with the real color-mode provider
|
|
||||||
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ChakraProvider value={defaultSystem}>
|
|
||||||
<ColorModeProvider>{children}</ColorModeProvider>
|
|
||||||
</ChakraProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Toaster as ChakraToaster,
|
|
||||||
Portal,
|
|
||||||
Spinner,
|
|
||||||
Stack,
|
|
||||||
Toast,
|
|
||||||
createToaster,
|
|
||||||
} from "@chakra-ui/react"
|
|
||||||
|
|
||||||
export const toaster = createToaster({
|
|
||||||
placement: "bottom-end",
|
|
||||||
pauseOnPageIdle: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Toaster = () => {
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
|
|
||||||
{(toast) => (
|
|
||||||
<Toast.Root width={{ md: "sm" }}>
|
|
||||||
{toast.type === "loading" ? (
|
|
||||||
<Spinner size="sm" color="blue.solid" />
|
|
||||||
) : (
|
|
||||||
<Toast.Indicator />
|
|
||||||
)}
|
|
||||||
<Stack gap="1" flex="1" maxWidth="100%">
|
|
||||||
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
|
||||||
{toast.description && (
|
|
||||||
<Toast.Description>{toast.description}</Toast.Description>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
{toast.action && (
|
|
||||||
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
|
|
||||||
)}
|
|
||||||
{toast.closable && <Toast.CloseTrigger />}
|
|
||||||
</Toast.Root>
|
|
||||||
)}
|
|
||||||
</ChakraToaster>
|
|
||||||
</Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
export interface TooltipProps extends ChakraTooltip.RootProps {
|
|
||||||
showArrow?: boolean
|
|
||||||
portalled?: boolean
|
|
||||||
portalRef?: React.RefObject<HTMLElement | null>
|
|
||||||
content: React.ReactNode
|
|
||||||
contentProps?: ChakraTooltip.ContentProps
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
|
||||||
function Tooltip(props, ref) {
|
|
||||||
const {
|
|
||||||
showArrow,
|
|
||||||
children,
|
|
||||||
disabled,
|
|
||||||
portalled = true,
|
|
||||||
content,
|
|
||||||
contentProps,
|
|
||||||
portalRef,
|
|
||||||
...rest
|
|
||||||
} = props
|
|
||||||
|
|
||||||
if (disabled) return children
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChakraTooltip.Root {...rest}>
|
|
||||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
|
||||||
<Portal disabled={!portalled} container={portalRef}>
|
|
||||||
<ChakraTooltip.Positioner>
|
|
||||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
|
||||||
{showArrow && (
|
|
||||||
<ChakraTooltip.Arrow>
|
|
||||||
<ChakraTooltip.ArrowTip />
|
|
||||||
</ChakraTooltip.Arrow>
|
|
||||||
)}
|
|
||||||
{content}
|
|
||||||
</ChakraTooltip.Content>
|
|
||||||
</ChakraTooltip.Positioner>
|
|
||||||
</Portal>
|
|
||||||
</ChakraTooltip.Root>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export const API_BASE =
|
|
||||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
|
||||||
import.meta.env.VITE_BACKEND_URL ||
|
|
||||||
"http://localhost:8002";
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
|
||||||
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
|
||||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
|
|
||||||
format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Display";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "SF Pro Text";
|
|
||||||
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
|
|
||||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Provider } from "@/components/ui/provider";
|
|
||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import "./index.css";
|
|
||||||
import App from "./App.tsx";
|
|
||||||
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
|
|
||||||
|
|
||||||
// Prevent unused variable tree shaking
|
|
||||||
let i18nUnused = i18n;
|
|
||||||
console.log(i18nUnused);
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<Provider>
|
|
||||||
<App />
|
|
||||||
</Provider>
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import {
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Spinner,
|
|
||||||
VStack,
|
|
||||||
Table,
|
|
||||||
InputGroup,
|
|
||||||
Span,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { getBorrowableItems } from "@/utils/Fetcher";
|
|
||||||
import { useState } from "react";
|
|
||||||
import MyAlert from "@/components/myChakra/MyAlert";
|
|
||||||
import { borrowAbleItemsAtom } from "@/states/Atoms";
|
|
||||||
import { createLoan } from "@/utils/Fetcher";
|
|
||||||
import { Header } from "@/components/Header";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
username: string;
|
|
||||||
role: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HomePage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
|
|
||||||
const [startDate, setStartDate] = useState("");
|
|
||||||
const [endDate, setEndDate] = useState("");
|
|
||||||
const [isLoadingA, setIsLoadingA] = useState(false);
|
|
||||||
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const MAX_CHARACTERS = 500;
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
|
|
||||||
// Error handling states
|
|
||||||
const [isMsg, setIsMsg] = useState(false);
|
|
||||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
|
||||||
const [msgTitle, setMsgTitle] = useState("");
|
|
||||||
const [msgDescription, setMsgDescription] = useState("");
|
|
||||||
|
|
||||||
const handleCheckboxChange = (itemId: number) => {
|
|
||||||
setSelectedItems((prevSelected) =>
|
|
||||||
prevSelected.includes(itemId)
|
|
||||||
? prevSelected.filter((id) => id !== itemId)
|
|
||||||
: [...prevSelected, itemId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container className="px-6 sm:px-8 pt-10">
|
|
||||||
<Header />
|
|
||||||
{isMsg && (
|
|
||||||
<MyAlert
|
|
||||||
status={msgStatus}
|
|
||||||
title={msgTitle}
|
|
||||||
description={msgDescription}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Stack as="main">
|
|
||||||
<Text>{t("timezone-info")}</Text>
|
|
||||||
<label htmlFor="startDate">
|
|
||||||
<strong>
|
|
||||||
<Text>{t("start-date")}</Text>
|
|
||||||
</strong>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="startDate"
|
|
||||||
placeholder={t("start-date")}
|
|
||||||
type="datetime-local"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label htmlFor="endDate">
|
|
||||||
<strong>
|
|
||||||
<Text>{t("end-date")}</Text>
|
|
||||||
</strong>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="endDate"
|
|
||||||
placeholder={t("end-date")}
|
|
||||||
type="datetime-local"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoadingA(true);
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(t("missing-fields"));
|
|
||||||
setMsgDescription(t("missing-fields-desc"));
|
|
||||||
setIsMsg(true);
|
|
||||||
setIsLoadingA(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await getBorrowableItems(startDate, endDate).then((response) => {
|
|
||||||
setIsLoadingA(false);
|
|
||||||
if (response && response.status === "error") {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(response.title || t("error"));
|
|
||||||
setMsgDescription(response.description || t("unknown-error"));
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBorrowableItems(response.data);
|
|
||||||
setIsMsg(false);
|
|
||||||
console.log(borrowableItems);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("get-borrowable-items")}
|
|
||||||
</Button>
|
|
||||||
{isLoadingA && (
|
|
||||||
<VStack colorPalette="teal">
|
|
||||||
<Spinner color="colorPalette.600" />
|
|
||||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
{borrowableItems.length > 0 && (
|
|
||||||
<Table.ScrollArea borderWidth="1px" rounded="md">
|
|
||||||
<Table.Root size="sm" stickyHeader>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row bg="bg.subtle">
|
|
||||||
<Table.ColumnHeader></Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
|
|
||||||
<Table.Body>
|
|
||||||
{borrowableItems.map((item) => (
|
|
||||||
<Table.Row key={item.id}>
|
|
||||||
<Table.Cell>
|
|
||||||
<input
|
|
||||||
onChange={() => handleCheckboxChange(item.id)}
|
|
||||||
type="checkbox"
|
|
||||||
name={item.id}
|
|
||||||
id={item.id}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{item.item_name}</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colSpan={2}>
|
|
||||||
<InputGroup
|
|
||||||
endElement={
|
|
||||||
<Span color="fg.muted" textStyle="xs">
|
|
||||||
{note.length} / {MAX_CHARACTERS}
|
|
||||||
</Span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder={t("optional-note")}
|
|
||||||
value={note}
|
|
||||||
maxLength={MAX_CHARACTERS}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNote(
|
|
||||||
e.currentTarget.value.slice(0, MAX_CHARACTERS)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</Table.ScrollArea>
|
|
||||||
)}
|
|
||||||
{selectedItems.length >= 1 && (
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
createLoan(selectedItems, startDate, endDate, note).then(
|
|
||||||
(response) => {
|
|
||||||
if (response.status === "error") {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(response.title || t("error"));
|
|
||||||
setMsgDescription(
|
|
||||||
response.description || t("unknown-error")
|
|
||||||
);
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMsgStatus("success");
|
|
||||||
setMsgTitle(t("success"));
|
|
||||||
setMsgDescription(t("loan-success"));
|
|
||||||
setIsMsg(true);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("create-loan")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import MyAlert from "../components/myChakra/MyAlert";
|
|
||||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
|
||||||
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { Navigate, useNavigate } 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 = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
|
||||||
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoggedIn) {
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden
|
|
||||||
}
|
|
||||||
}, [isLoggedIn, navigate]);
|
|
||||||
|
|
||||||
const loginFnc = async (username: string, password: string) => {
|
|
||||||
const response = await fetch(`${API_BASE}/api/users/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: data.message ?? t("login-failed"),
|
|
||||||
description: data.description ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Cookies.set("token", data.token);
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
const [errorMsg, setErrorMsg] = useState("");
|
|
||||||
const [errorDsc, setErrorDsc] = useState("");
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
const result = await loginFnc(username, password);
|
|
||||||
if (!result.success) {
|
|
||||||
setErrorMsg(result.message);
|
|
||||||
setErrorDsc(result.description);
|
|
||||||
setIsError(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTriggerLogout(false);
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
|
||||||
<Card.Root maxW="sm">
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title>{t("login")}</Card.Title>
|
|
||||||
<Card.Description>{t("enter-credentials")}</Card.Description>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body>
|
|
||||||
<Stack gap="4" w="full">
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>{t("username")}</Field.Label>
|
|
||||||
<Input
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>{t("password")}</Field.Label>
|
|
||||||
<PasswordInput
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field.Root>
|
|
||||||
</Stack>
|
|
||||||
</Card.Body>
|
|
||||||
<Card.Footer justifyContent="flex-end">
|
|
||||||
{isError && (
|
|
||||||
<MyAlert status="error" title={errorMsg} description={errorDsc} />
|
|
||||||
)}
|
|
||||||
<Button type="submit" onClick={() => handleLogin()} variant="solid">
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</Card.Footer>
|
|
||||||
<Card.Footer justifyContent="flex-end">
|
|
||||||
{triggerLogout && (
|
|
||||||
<MyAlert
|
|
||||||
status="success"
|
|
||||||
title={t("logout-success")}
|
|
||||||
description={t("logout-success-desc")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card.Footer>
|
|
||||||
</Card.Root>
|
|
||||||
</form>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import MyAlert from "@/components/myChakra/MyAlert";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
VStack,
|
|
||||||
Spinner,
|
|
||||||
Text,
|
|
||||||
Table,
|
|
||||||
Button,
|
|
||||||
CloseButton,
|
|
||||||
Dialog,
|
|
||||||
Portal,
|
|
||||||
Code,
|
|
||||||
Box,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { Header } from "@/components/Header";
|
|
||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
export const MyLoansPage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [loans, setLoans] = useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const [delLoanCode, setDelLoanCode] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Error handling states
|
|
||||||
const [isMsg, setIsMsg] = useState(false);
|
|
||||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
|
||||||
const [msgTitle, setMsgTitle] = useState("");
|
|
||||||
const [msgDescription, setMsgDescription] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Cookies.get("token")) {
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLoans = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const res = await fetch(`${API_BASE}/api/loans/loans`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(t("error"));
|
|
||||||
setMsgDescription(t("error-fetching-loans"));
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
setLoans(data);
|
|
||||||
} catch (e) {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(t("error"));
|
|
||||||
setMsgDescription(t("network-error-fetching-loans"));
|
|
||||||
setIsMsg(true);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchLoans();
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const deleteLoan = async (loanId: number) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/loans/delete-loan/${loanId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(t("error"));
|
|
||||||
setMsgDescription(t("error-deleting-loan"));
|
|
||||||
setIsMsg(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoans((prev) => prev.filter((loan) => loan.id !== loanId));
|
|
||||||
setMsgStatus("success");
|
|
||||||
setMsgTitle(t("success"));
|
|
||||||
setMsgDescription(t("loan-deletion-success"));
|
|
||||||
setIsMsg(true);
|
|
||||||
} catch (e) {
|
|
||||||
setMsgStatus("error");
|
|
||||||
setMsgTitle(t("error"));
|
|
||||||
setMsgDescription(t("network-error-deleting-loan"));
|
|
||||||
setIsMsg(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso: string | null) => {
|
|
||||||
if (!iso) return "-";
|
|
||||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
||||||
if (!m) return iso;
|
|
||||||
const [, y, M, d, h, min] = m;
|
|
||||||
return `${d}.${M}.${y} ${h}:${min}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container className="px-6 sm:px-8 pt-10">
|
|
||||||
<Header />
|
|
||||||
{isMsg && (
|
|
||||||
<MyAlert
|
|
||||||
status={msgStatus}
|
|
||||||
title={msgTitle}
|
|
||||||
description={msgDescription}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<VStack colorPalette="teal">
|
|
||||||
<Spinner color="colorPalette.600" />
|
|
||||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
{loans && (
|
|
||||||
<Box
|
|
||||||
overflowX="auto"
|
|
||||||
width="100%"
|
|
||||||
// Optional: add bottom padding to avoid scrollbar overlap
|
|
||||||
pb={2}
|
|
||||||
>
|
|
||||||
<Table.Root
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
// minWidth ensures we don't cram all columns on tiny screens;
|
|
||||||
// horizontal scrolling will appear instead.
|
|
||||||
style={{ tableLayout: "fixed", width: "100%", minWidth: "800px" }}
|
|
||||||
>
|
|
||||||
<Table.ColumnGroup>
|
|
||||||
{/* Ausleihcode */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Startdatum */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Enddatum */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Geräte (flexibler) */}
|
|
||||||
<Table.Column style={{ width: "28%" }} />
|
|
||||||
{/* Ausleihdatum */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Rückgabedatum */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Notiz */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Aktionen */}
|
|
||||||
<Table.Column style={{ width: "8%" }} />
|
|
||||||
</Table.ColumnGroup>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.ColumnHeader>{t("loan-code")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("start-date")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("end-date")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("note")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("actions")}</Table.ColumnHeader>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{loans.map((loan) => (
|
|
||||||
<Table.Row key={loan.id}>
|
|
||||||
<Table.Cell>
|
|
||||||
<Text title={loan.loan_code}>
|
|
||||||
<Code variant="solid">{`${loan.loan_code}`}</Code>
|
|
||||||
</Text>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Text>
|
|
||||||
{Array.isArray(loan.loaned_items_name)
|
|
||||||
? loan.loaned_items_name.join(", ")
|
|
||||||
: "-"}
|
|
||||||
</Text>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
|
|
||||||
<Table.Cell>{loan.note}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Dialog.Root role="alertdialog">
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDelLoanCode(loan.loan_code)}
|
|
||||||
aria-label="Ausleihe löschen"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Portal>
|
|
||||||
<Dialog.Backdrop />
|
|
||||||
<Dialog.Positioner>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>{t("sure")}</Dialog.Title>
|
|
||||||
</Dialog.Header>
|
|
||||||
<Dialog.Body>
|
|
||||||
<Text>
|
|
||||||
{t("sure-delete-loan-0")}
|
|
||||||
<strong>
|
|
||||||
<Code>{delLoanCode}</Code>
|
|
||||||
</strong>{" "}
|
|
||||||
{t("sure-delete-loan-1")}
|
|
||||||
<br />
|
|
||||||
{t("sure-delete-loan-2")}
|
|
||||||
</Text>
|
|
||||||
</Dialog.Body>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Dialog.ActionTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</Dialog.ActionTrigger>
|
|
||||||
<Button
|
|
||||||
colorPalette="red"
|
|
||||||
onClick={() => deleteLoan(loan.id)}
|
|
||||||
>
|
|
||||||
<strong>{t("delete")}</strong>
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
<Dialog.CloseTrigger asChild>
|
|
||||||
<CloseButton size="sm" />
|
|
||||||
</Dialog.CloseTrigger>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Positioner>
|
|
||||||
</Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
interface Meta {
|
|
||||||
"backend-info": {
|
|
||||||
version: String;
|
|
||||||
};
|
|
||||||
"frontend-info": {
|
|
||||||
version: String;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testAtom = atom<number>(0);
|
|
||||||
export const setIsLoggedInAtom = atom<boolean>(false);
|
|
||||||
export const triggerLogoutAtom = atom<boolean>(false);
|
|
||||||
export const borrowAbleItemsAtom = atom<any[]>([]);
|
|
||||||
export const infoAtom = atom<Meta | undefined>(undefined);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
import { useContext } from "react";
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
username: string;
|
|
||||||
is_admin: boolean;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
role: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UserContext = createContext<User | undefined>(undefined);
|
|
||||||
|
|
||||||
export function useUserContext() {
|
|
||||||
const user = useContext(UserContext);
|
|
||||||
|
|
||||||
if (user === undefined) {
|
|
||||||
throw new Error("useUserContext must be used with a UserContext");
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# How to use Atoms
|
|
||||||
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
|
||||||
|
|
||||||
You can also name it global state.
|
|
||||||
|
|
||||||
## Creating an Atom
|
|
||||||
to create an atom you have to declare an atom like this:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
|
||||||
```
|
|
||||||
|
|
||||||
In this project we declare all atoms in the `states/Atoms.tsx`file. Which you can find above this README file.
|
|
||||||
|
|
||||||
## Using an Atom
|
|
||||||
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { NAME_OF_YOUR_ATOM } from '@/states/Atoms';
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>Current value: {value}</p>
|
|
||||||
<button onClick={() => setValue(newValue)}>Update Value</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Cookies from "js-cookie";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
export const getBorrowableItems = async (
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ startDate, endDate }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
status: "error",
|
|
||||||
title: "Server error",
|
|
||||||
description:
|
|
||||||
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return {
|
|
||||||
data: data,
|
|
||||||
status: "success",
|
|
||||||
title: null,
|
|
||||||
description: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
status: "error",
|
|
||||||
title: "Netzwerkfehler",
|
|
||||||
description:
|
|
||||||
"Es konnte keine Verbindung zum Server hergestellt werden. Bitte überprüfe deine Internetverbindung.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createLoan = async (
|
|
||||||
itemIds: number[],
|
|
||||||
startDate: string,
|
|
||||||
endDate: string,
|
|
||||||
note: string | null
|
|
||||||
) => {
|
|
||||||
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ items: itemIds, startDate, endDate, note }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
status: "error",
|
|
||||||
title: "Server error",
|
|
||||||
description:
|
|
||||||
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return {
|
|
||||||
data: data,
|
|
||||||
status: "success",
|
|
||||||
title: null,
|
|
||||||
description: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { UserContext } from "@/states/Context";
|
|
||||||
|
|
||||||
export const ProtectedRoutes = () => {
|
|
||||||
const user = useContext(UserContext);
|
|
||||||
const location = useLocation();
|
|
||||||
const hasToken = Boolean(Cookies.get("token"));
|
|
||||||
|
|
||||||
if (hasToken && !user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user ? (
|
|
||||||
<Outlet />
|
|
||||||
) : (
|
|
||||||
<Navigate to="/login" replace state={{ from: location }} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import i18n from "i18next";
|
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
import enLang from "./locales/en/en.json";
|
|
||||||
import deLang from "./locales/de/de.json";
|
|
||||||
|
|
||||||
// the translations
|
|
||||||
// (tip move them in a JSON file and import them,
|
|
||||||
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
|
|
||||||
const resources = {
|
|
||||||
en: {
|
|
||||||
translation: enLang,
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
translation: deLang,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
i18n
|
|
||||||
.use(initReactI18next) // passes i18n down to react-i18next
|
|
||||||
.init({
|
|
||||||
resources,
|
|
||||||
fallbackLng: "en", // use en if detected lng is not available
|
|
||||||
lng: Cookies.get("language") || "en", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
|
|
||||||
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
|
|
||||||
// if you're using a language detector, do not define the lng option
|
|
||||||
|
|
||||||
interpolation: {
|
|
||||||
escapeValue: false, // react already safes from xss
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default i18n;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"greeting": "Willkommen zurück, ",
|
|
||||||
"err_pw_change": "Passwortänderung fehlgeschlagen",
|
|
||||||
"pw_mismatch": "Bitte überprüfen Sie Ihre Eingaben",
|
|
||||||
"pw_success": "Passwort erfolgreich geändert",
|
|
||||||
"pw_success_desc": "Ihr Passwort wurde erfolgreich geändert.",
|
|
||||||
"create-loan": "Ausleihe erstellen",
|
|
||||||
"my-loans": "Meine Ausleihen",
|
|
||||||
"change-password": "Passwort ändern",
|
|
||||||
"help": "Hilfe",
|
|
||||||
"source-code": "Quellcode",
|
|
||||||
"logout": "Abmelden",
|
|
||||||
"old-password": "Altes Passwort",
|
|
||||||
"new-password": "Neues Passwort",
|
|
||||||
"confirm-password": "Neues Passwort wiederholen",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"save": "Speichern",
|
|
||||||
"start-date": "Startdatum",
|
|
||||||
"end-date": "Enddatum",
|
|
||||||
"missing-fields": "Fehlende Eingaben",
|
|
||||||
"missing-fields-desc": "Bitte Start- und Enddatum angeben.",
|
|
||||||
"error": "Fehler",
|
|
||||||
"unknown-error": "Unbekannter Frontend Fehler",
|
|
||||||
"get-borrowable-items": "Verfügbare Gegenstände abrufen",
|
|
||||||
"loading": "Laden...",
|
|
||||||
"item": "Gegenstand",
|
|
||||||
"success": "Erfolg",
|
|
||||||
"loan-success": "Ausleihe erfolgreich erstellt",
|
|
||||||
"error-by-loading": "Fehler beim Laden",
|
|
||||||
"unexpected-date-format_loan": "Unerwartetes Datumsformat erhalten. (Ausleihen)",
|
|
||||||
"unexpected-date-format_device": "Unerwartetes Datumsformat erhalten. (Gerät)",
|
|
||||||
"error-fetching-loans": "Die Ausleihen konnten nicht abgerufen werden.",
|
|
||||||
"all-loans": "Alle Ausleihen",
|
|
||||||
"username": "Benutzername",
|
|
||||||
"rented-items": "Ausgeliehene Gegenstände",
|
|
||||||
"return-date": "Rückgabedatum",
|
|
||||||
"take-date": "Abholdatum",
|
|
||||||
"no-loans-found": "Keine Ausleihen vorhanden.",
|
|
||||||
"all-devices": "Alle Geräte",
|
|
||||||
"rent-role": "Ausleihrolle",
|
|
||||||
"legend": "Legende",
|
|
||||||
"in-locker": "Im Schließfach",
|
|
||||||
"not-in-locker": "Nicht im Schließfach",
|
|
||||||
"login-failed": "Anmeldung fehlgeschlagen",
|
|
||||||
"login": "Anmelden",
|
|
||||||
"enter-credentials": "Bitte unten Ihre Anmeldedaten eingeben.",
|
|
||||||
"password": "Passwort",
|
|
||||||
"logout-success": "Erfolgreich abgemeldet",
|
|
||||||
"logout-success-desc": "Sie wurden erfolgreich abgemeldet.",
|
|
||||||
"network-error-fetching-loans": "Netzwerkfehler beim Laden der Ausleihen.",
|
|
||||||
"error-deleting-loan": "Die Ausleihe konnte nicht gelöscht werden.",
|
|
||||||
"loan-deletion-success": "Die Ausleihe wurde erfolgreich gelöscht.",
|
|
||||||
"network-error-deleting-loan": "Netzwerkfehler beim Löschen der Ausleihe.",
|
|
||||||
"loan-code": "Ausleihcode",
|
|
||||||
"devices": "Geräte",
|
|
||||||
"actions": "Aktionen",
|
|
||||||
"sure": "Sind Sie sicher?",
|
|
||||||
"sure-delete-loan-0": "Möchten Sie die Ausleihe mit dem ",
|
|
||||||
"sure-delete-loan-1": " Ausleihcode wirklich löschen?",
|
|
||||||
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
|
|
||||||
"delete": "Löschen",
|
|
||||||
"change-language": "Sprache ändern",
|
|
||||||
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
|
|
||||||
"optional-note": "Optionale Notiz",
|
|
||||||
"note": "Notiz",
|
|
||||||
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
|
|
||||||
"role": "Rolle",
|
|
||||||
"admin-status": "Admin-Status",
|
|
||||||
"first-name": "Vorname",
|
|
||||||
"last-name": "Nachname",
|
|
||||||
"app-title": "Ausleihsystem",
|
|
||||||
"last-borrowed-person": "Zuletzt ausgeliehen von",
|
|
||||||
"currently-borrowed-by": "Derzeit ausgeliehen von",
|
|
||||||
"back": "Zurückgehen",
|
|
||||||
"landingpage": "Übersichtsseite"
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"greeting": "Welcome back, ",
|
|
||||||
"err_pw_change": "Password change failed",
|
|
||||||
"pw_mismatch": "Please check your input",
|
|
||||||
"pw_success": "Password changed successfully",
|
|
||||||
"pw_success_desc": "Your password was changed successfully.",
|
|
||||||
"create-loan": "Create loan",
|
|
||||||
"my-loans": "My loans",
|
|
||||||
"change-password": "Change password",
|
|
||||||
"help": "Help",
|
|
||||||
"source-code": "Source code",
|
|
||||||
"logout": "Log out",
|
|
||||||
"old-password": "Old password",
|
|
||||||
"new-password": "New password",
|
|
||||||
"confirm-password": "Repeat new password",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"save": "Save",
|
|
||||||
"start-date": "Start date",
|
|
||||||
"end-date": "End date",
|
|
||||||
"missing-fields": "Missing fields",
|
|
||||||
"missing-fields-desc": "Please provide start and end date.",
|
|
||||||
"error": "Error",
|
|
||||||
"unknown-error": "Unknown frontend error",
|
|
||||||
"get-borrowable-items": "Fetch available items",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"item": "Item",
|
|
||||||
"success": "Success",
|
|
||||||
"loan-success": "Loan created successfully",
|
|
||||||
"error-by-loading": "Error while loading",
|
|
||||||
"unexpected-date-format_loan": "Unexpected date format received. (Loans)",
|
|
||||||
"unexpected-date-format_device": "Unexpected date format received. (Device)",
|
|
||||||
"error-fetching-loans": "The loans could not be retrieved.",
|
|
||||||
"all-loans": "All loans",
|
|
||||||
"username": "Username",
|
|
||||||
"rented-items": "Borrowed items",
|
|
||||||
"return-date": "Return date",
|
|
||||||
"take-date": "Collection date",
|
|
||||||
"no-loans-found": "No loans found.",
|
|
||||||
"all-devices": "All devices",
|
|
||||||
"rent-role": "Loan role",
|
|
||||||
"legend": "Legend",
|
|
||||||
"in-locker": "In locker",
|
|
||||||
"not-in-locker": "Not in locker",
|
|
||||||
"login-failed": "Login failed",
|
|
||||||
"login": "Log in",
|
|
||||||
"enter-credentials": "Please enter your credentials below.",
|
|
||||||
"password": "Password",
|
|
||||||
"logout-success": "Successfully logged out",
|
|
||||||
"logout-success-desc": "You have been logged out successfully.",
|
|
||||||
"network-error-fetching-loans": "Network error while loading loans.",
|
|
||||||
"error-deleting-loan": "The loan could not be deleted.",
|
|
||||||
"loan-deletion-success": "The loan was deleted successfully.",
|
|
||||||
"network-error-deleting-loan": "Network error while deleting the loan.",
|
|
||||||
"loan-code": "Loan code",
|
|
||||||
"devices": "Devices",
|
|
||||||
"actions": "Actions",
|
|
||||||
"sure": "Are you sure?",
|
|
||||||
"sure-delete-loan-0": "Do you really want to delete the loan with the ",
|
|
||||||
"sure-delete-loan-1": " loan code?",
|
|
||||||
"sure-delete-loan-2": "It will remain visible to the admin.",
|
|
||||||
"delete": "Delete",
|
|
||||||
"change-language": "Change language",
|
|
||||||
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
|
|
||||||
"optional-note": "Optional note",
|
|
||||||
"note": "Note",
|
|
||||||
"user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
|
|
||||||
"role": "Role",
|
|
||||||
"admin-status": "Admin status",
|
|
||||||
"first-name": "First name",
|
|
||||||
"last-name": "Last name",
|
|
||||||
"app-title": "Borrow System",
|
|
||||||
"last-borrowed-person": "Last borrowed by",
|
|
||||||
"currently-borrowed-by": "Currently borrowed by",
|
|
||||||
"back": "Go back",
|
|
||||||
"landingpage": "Overview page"
|
|
||||||
}
|
|
||||||
276
Mock/AppMockup.tsx
Normal file
276
Mock/AppMockup.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// Beispiel-Daten für die Übersicht in der Seitenleiste
|
||||||
|
const allItems = [
|
||||||
|
{ id: 1, name: "Kamera" },
|
||||||
|
{ id: 2, name: "Mikrofon" },
|
||||||
|
{ id: 3, name: "Licht-Set" },
|
||||||
|
{ id: 4, name: "Stativ" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Beispiel-Ausleihen, später per API dynamisch!
|
||||||
|
const loans = [
|
||||||
|
{
|
||||||
|
itemId: 1,
|
||||||
|
username: "max",
|
||||||
|
start: "2025-01-01T08:00",
|
||||||
|
end: "2025-01-01T18:00",
|
||||||
|
loanCode: "123456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 3,
|
||||||
|
username: "sara",
|
||||||
|
start: "2025-01-02T10:00",
|
||||||
|
end: "2025-01-02T16:00",
|
||||||
|
loanCode: "654321",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dummy: Für das Beispiel sind einige Items "nicht verfügbar" bei bestimmten Zeiträumen
|
||||||
|
function getAvailableItems(start: string, end: string) {
|
||||||
|
if (start.startsWith("2025-01-01")) {
|
||||||
|
return allItems.filter(
|
||||||
|
(item) => item.name === "Kamera" || item.name === "Stativ"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [availableItems, setAvailableItems] = useState<typeof allItems>([]);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Dummy Code für das Design
|
||||||
|
const loanCode = "123456";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
|
||||||
|
{/* Seitenleiste */}
|
||||||
|
<aside className="w-80 min-h-screen bg-white/90 backdrop-blur border-r border-blue-100 shadow-xl flex flex-col p-8">
|
||||||
|
<h2 className="text-2xl font-extrabold mb-6 text-blue-700 tracking-tight flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 text-blue-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Ausleih-Übersicht
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-5 flex-1">
|
||||||
|
{allItems.map((item) => {
|
||||||
|
const itemLoans = loans.filter((loan) => loan.itemId === item.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
itemLoans.length === 0 ? "#34d399" : "#60a5fa",
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{itemLoans.length === 0 ? (
|
||||||
|
<div className="text-green-500 text-xs mt-1 font-medium">
|
||||||
|
Verfügbar
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{itemLoans.map((loan, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="text-xs text-blue-800 bg-blue-100/60 p-1 rounded"
|
||||||
|
>
|
||||||
|
<span className="font-bold">{loan.username}</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
{formatDateTime(loan.start)} –{" "}
|
||||||
|
{formatDateTime(loan.end)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-gray-400">
|
||||||
|
({loan.loanCode})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-10 text-xs text-gray-400 flex items-center gap-4">
|
||||||
|
<span className="inline-block w-3 h-3 bg-green-400 rounded-full mr-1"></span>
|
||||||
|
Verfügbar
|
||||||
|
<span className="inline-block w-3 h-3 bg-blue-400 rounded-full ml-4 mr-1"></span>
|
||||||
|
Verliehen
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Hauptbereich */}
|
||||||
|
<main className="flex-1 flex flex-col items-center py-14 px-4">
|
||||||
|
<header className="mb-12">
|
||||||
|
<h1 className="text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
|
||||||
|
Gegenstand ausleihen
|
||||||
|
</h1>
|
||||||
|
<p className="text-blue-400 mt-2 text-lg font-medium">
|
||||||
|
Schnell und unkompliziert Equipment reservieren
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="bg-white/90 shadow-2xl rounded-3xl p-10 w-full max-w-xl ring-1 ring-blue-100">
|
||||||
|
{step === 1 && (
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAvailableItems(getAvailableItems(startDate, endDate));
|
||||||
|
setStep(2);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-bold mb-2 text-blue-700">
|
||||||
|
1. Zeitraum wählen
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block font-medium mb-1 text-blue-900">
|
||||||
|
Startdatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block font-medium mb-1 text-blue-900">
|
||||||
|
Enddatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
min={startDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition disabled:bg-gray-300"
|
||||||
|
disabled={!startDate || !endDate || endDate <= startDate}
|
||||||
|
>
|
||||||
|
Verfügbare Gegenstände anzeigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold mb-6 text-blue-700">
|
||||||
|
2. Gegenstand auswählen
|
||||||
|
</h2>
|
||||||
|
{availableItems.length === 0 ? (
|
||||||
|
<div className="text-red-600 mb-8 font-medium text-center">
|
||||||
|
Keine Gegenstände verfügbar für diesen Zeitraum.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-8 space-y-3">
|
||||||
|
{availableItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`flex justify-between items-center p-4 rounded-xl shadow-sm border ${
|
||||||
|
selectedItem === item.id
|
||||||
|
? "bg-blue-100 border-blue-400"
|
||||||
|
: "bg-green-50 border-green-100 hover:bg-blue-50"
|
||||||
|
} transition`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-lg">{item.name}</span>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-blue-400 text-white text-sm font-semibold shadow hover:from-blue-600 hover:to-blue-500 ${
|
||||||
|
selectedItem === item.id ? "ring-2 ring-blue-400" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedItem(item.id)}
|
||||||
|
>
|
||||||
|
{selectedItem === item.id ? "Ausgewählt" : "Auswählen"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
className="px-5 py-2 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 font-semibold shadow"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-5 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow transition disabled:bg-gray-300"
|
||||||
|
disabled={selectedItem === null}
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
>
|
||||||
|
Ausleihen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="mt-2 p-8 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
|
||||||
|
<h3 className="font-extrabold text-blue-700 mb-3 text-2xl">
|
||||||
|
Ausleihe bestätigt!
|
||||||
|
</h3>
|
||||||
|
<p className="mb-2 text-lg">
|
||||||
|
Ihr Ausleih-Code lautet:{" "}
|
||||||
|
<span className="font-mono text-2xl text-blue-900 bg-white px-2 py-1 rounded shadow">
|
||||||
|
{loanCode}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-blue-600 text-sm">
|
||||||
|
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mt-8 px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow"
|
||||||
|
onClick={() => {
|
||||||
|
setStep(1);
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setSelectedItem(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Neue Ausleihe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Datumsformatierung (z.B. 01.01.2025 08:00)
|
||||||
|
function formatDateTime(dt: string) {
|
||||||
|
const d = new Date(dt);
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) +
|
||||||
|
" " +
|
||||||
|
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||||
|
);
|
||||||
|
}
|
||||||
20
Mock/frontendMockData.json
Normal file
20
Mock/frontendMockData.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Mock Book 1",
|
||||||
|
"author": "Author 1",
|
||||||
|
"description": "Description for Mock Book 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Mock Book 2",
|
||||||
|
"author": "Author 2",
|
||||||
|
"description": "Description for Mock Book 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Mock Book 3",
|
||||||
|
"author": "Author 3",
|
||||||
|
"description": "Description for Mock Book 3"
|
||||||
|
}
|
||||||
|
]
|
||||||
72
README.md
72
README.md
@@ -1,73 +1,7 @@
|
|||||||
# Borrow System
|
# Borrow System
|
||||||
|
|
||||||

|
**You have reached the `debian12` branch.**
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
A small full‑stack system to log in, view available items, reserve them for a time window, and manage personal loans.
|
Here you will find the source code of exactly the application that I have hosted.
|
||||||
|
|
||||||
- Frontend: React + TypeScript + Vite + Tailwind CSS
|
The main branch or the branch that I am developing on, is the `dev` branch.
|
||||||
- Backend: Node.js + Express + MySQL + JWT (jose)
|
|
||||||
- Orchestration: Docker Compose (backend + MySQL)
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- Frontend: [frontend/](frontend)
|
|
||||||
- Vite/Tailwind config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
|
|
||||||
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
|
|
||||||
- UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
|
|
||||||
- Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
|
|
||||||
- Backend: [backend/](backend)
|
|
||||||
- Server: [backend/server.js](backend/server.js)
|
|
||||||
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
|
|
||||||
- DB + services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
|
|
||||||
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
|
|
||||||
- Docs: [docs/](docs)
|
|
||||||
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
|
|
||||||
|
|
||||||
## Features (high‑level)
|
|
||||||
|
|
||||||
- Auth via JWT (login -> token cookie) using the backend route in [backend/routes/api.js](backend/routes/api.js).
|
|
||||||
- After login, the app loads items, loans, and user loans and keeps them in localStorage.
|
|
||||||
- Choose a date range to fetch borrowable items, select items, and create a loan.
|
|
||||||
- Manage personal loans list (and delete a loan).
|
|
||||||
|
|
||||||
Key frontend utilities:
|
|
||||||
|
|
||||||
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
|
|
||||||
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected time range.
|
|
||||||
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
|
|
||||||
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
|
|
||||||
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
|
|
||||||
|
|
||||||
UI flow (main screens):
|
|
||||||
|
|
||||||
- Period selection: [frontend/src/components/Form1.tsx](frontend/src/components/Form1.tsx)
|
|
||||||
- Borrowable items + selection: [frontend/src/components/Form2.tsx](frontend/src/components/Form2.tsx)
|
|
||||||
- User loans table: [frontend/src/components/Form4.tsx](frontend/src/components/Form4.tsx)
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
- Scripts: see [frontend/package.json](frontend/package.json) and [backend/package.json](backend/package.json)
|
|
||||||
- Frontend: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint`
|
|
||||||
- Backend: `npm start`
|
|
||||||
- Linting: ESLint configured via [frontend/eslint.config.js](frontend/eslint.config.js)
|
|
||||||
- TypeScript configs: [frontend/tsconfig.app.json](frontend/tsconfig.app.json), [frontend/tsconfig.node.json](frontend/tsconfig.node.json)
|
|
||||||
|
|
||||||
## Configuration notes
|
|
||||||
|
|
||||||
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
|
|
||||||
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
|
|
||||||
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Cross‑component updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
|
|
||||||
|
|
||||||
## API documentation
|
|
||||||
|
|
||||||
Refer to the dedicated API docs:
|
|
||||||
|
|
||||||
`docs/backend_API_docs/README.md`
|
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
FROM node:18 as builder
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
EXPOSE 8003
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
CMD ["npm", "run", "dev"]
|
||||||
COPY --from=builder /app/dist .
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /backend {
|
|
||||||
return 301 /backend/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /backend/ {
|
|
||||||
proxy_pass http://borrow_system-backend_v2:8102/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
|
||||||
expires 1y;
|
|
||||||
access_log off;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
admin/package-lock.json
generated
48
admin/package-lock.json
generated
@@ -12,7 +12,6 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"jotai": "^2.15.0",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -4421,35 +4420,6 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jotai": {
|
|
||||||
"version": "2.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
|
|
||||||
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@babel/core": ">=7.0.0",
|
|
||||||
"@babel/template": ">=7.0.0",
|
|
||||||
"@types/react": ">=17.0.0",
|
|
||||||
"react": ">=17.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@babel/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@babel/template": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
@@ -5675,13 +5645,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.4.4",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -5879,9 +5849,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.11",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -5889,7 +5859,7 @@
|
|||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.43.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.14"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"jotai": "^2.15.0",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -3,23 +3,33 @@ import { useEffect } from "react";
|
|||||||
import Dashboard from "./Dashboard";
|
import Dashboard from "./Dashboard";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
import Landingpage from "@/components/API/Landingpage";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
const Layout: React.FC = () => {
|
const Layout: React.FC = () => {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [showAPI, setShowAPI] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname.replace(/\/+$/, ""); // remove trailing slash
|
||||||
|
if (path === "/api") {
|
||||||
|
setShowAPI(true);
|
||||||
|
console.log("signal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Cookies.get("token")) {
|
if (Cookies.get("token")) {
|
||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||||
`${API_BASE}/api/admin/user-mgmt/verify-token`,
|
method: "GET",
|
||||||
{
|
headers: {
|
||||||
method: "GET",
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -38,6 +48,14 @@ const Layout: React.FC = () => {
|
|||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showAPI) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Landingpage />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
viewAusleihen: () => void;
|
viewAusleihen: () => void;
|
||||||
@@ -17,22 +15,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
viewUser,
|
viewUser,
|
||||||
viewAPI,
|
viewAPI,
|
||||||
}) => {
|
}) => {
|
||||||
const [info, setInfo] = useState<any>(null);
|
|
||||||
|
|
||||||
const fetchInfo = async () => {
|
|
||||||
const response = await fetch(`${API_BASE}/`);
|
|
||||||
const data = await response.json();
|
|
||||||
setInfo(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="aside"
|
as="aside"
|
||||||
w="180px"
|
w="260px"
|
||||||
minH="100vh"
|
minH="100vh"
|
||||||
bg="gray.800"
|
bg="gray.800"
|
||||||
color="gray.100"
|
color="gray.100"
|
||||||
@@ -86,33 +72,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
||||||
<Text mb={2}>© Made with ❤️ by Theis Gaedigk</Text>
|
<Text>© Made with ❤️ by Theis Gaedigk</Text>
|
||||||
{info ? (
|
|
||||||
<Flex gap={2} wrap="wrap">
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
px={2}
|
|
||||||
py={0.5}
|
|
||||||
rounded="full"
|
|
||||||
bg="gray.700"
|
|
||||||
color="gray.200"
|
|
||||||
>
|
|
||||||
Panel {info?.["admin-panel-info"]?.version ?? "—"}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
px={2}
|
|
||||||
py={0.5}
|
|
||||||
rounded="full"
|
|
||||||
bg="gray.700"
|
|
||||||
color="gray.200"
|
|
||||||
>
|
|
||||||
Backend {info?.["backend-info"]?.version ?? "—"}
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Text color="gray.600">Lade Versionsinfos…</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export const testAtom = atom<number>(0);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# How to use Atoms
|
|
||||||
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
|
||||||
|
|
||||||
You can also name it global state.
|
|
||||||
|
|
||||||
## Creating an Atom
|
|
||||||
to create an atom you have to declare an atom like this:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { atom } from 'jotai';
|
|
||||||
|
|
||||||
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
|
||||||
```
|
|
||||||
|
|
||||||
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
|
|
||||||
|
|
||||||
## Using an Atom
|
|
||||||
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>Current value: {value}</p>
|
|
||||||
<button onClick={() => setValue(newValue)}>Update Value</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
|
||||||
@@ -10,19 +10,14 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Button,
|
Button,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import MyAlert from "@/components/myChakra/MyAlert";
|
import { Lock, LockOpen } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import MyAlert from "../myChakra/MyAlert";
|
||||||
import { API_BASE } from "@/config/api.config";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export const formatDateTime = (value: string | null | undefined) => {
|
const API_BASE =
|
||||||
if (!value) return "N/A";
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
if (!m) return "N/A";
|
"http://localhost:8002";
|
||||||
const [, y, M, d, h, min] = m;
|
|
||||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Loan = {
|
type Loan = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,16 +33,11 @@ type Device = {
|
|||||||
id: number;
|
id: number;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
can_borrow_role: string;
|
can_borrow_role: string;
|
||||||
in_safe: number;
|
inSafe: number;
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
last_borrowed_person: string | null;
|
|
||||||
currently_borrowing: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Landingpage: React.FC = () => {
|
const Landingpage: React.FC = () => {
|
||||||
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[]>([]);
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
@@ -72,41 +62,35 @@ const Landingpage: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const loanRes = await fetch(`${API_BASE}/api/loans/all-loans`, {
|
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const loanData = await loanRes.json();
|
const loanData = await loanRes.json();
|
||||||
if (Array.isArray(loanData)) {
|
if (Array.isArray(loanData)) {
|
||||||
setLoans(loanData);
|
setLoans(loanData);
|
||||||
} else {
|
} else {
|
||||||
setError(
|
setError(
|
||||||
"error",
|
"error",
|
||||||
t("error-by-loading"),
|
"Fehler beim Laden",
|
||||||
t("unexpected-date-format_loan")
|
"Unerwartetes Datenformat erhalten. (Ausleihen)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`, {
|
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const deviceData = await deviceRes.json();
|
const deviceData = await deviceRes.json();
|
||||||
if (Array.isArray(deviceData)) {
|
if (Array.isArray(deviceData)) {
|
||||||
setDevices(deviceData);
|
setDevices(deviceData);
|
||||||
} else {
|
} else {
|
||||||
setError(
|
setError(
|
||||||
"error",
|
"error",
|
||||||
t("error-by-loading"),
|
"Fehler beim Laden",
|
||||||
t("unexpected-date-format_device")
|
"Unerwartetes Datenformat erhalten. (Geräte)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError("error", t("error-by-loading"), t("error-fetching-loans"));
|
setError(
|
||||||
|
"error",
|
||||||
|
"Fehler beim Laden",
|
||||||
|
"Die Ausleihen konnten nicht geladen werden."
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -120,12 +104,8 @@ const Landingpage: React.FC = () => {
|
|||||||
Matthias-Claudius-Schule Technik
|
Matthias-Claudius-Schule Technik
|
||||||
</Heading>
|
</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")}
|
Alle Ausleihen
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
{isError && (
|
{isError && (
|
||||||
@@ -139,7 +119,7 @@ const Landingpage: React.FC = () => {
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<VStack colorPalette="teal">
|
<VStack colorPalette="teal">
|
||||||
<Spinner color="colorPalette.600" />
|
<Spinner color="colorPalette.600" />
|
||||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
<Text color="colorPalette.600">Loading...</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -151,22 +131,22 @@ const Landingpage: React.FC = () => {
|
|||||||
<strong>#</strong>
|
<strong>#</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("username")}</strong>
|
<strong>Benutzername</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("start-date")}</strong>
|
<strong>Startdatum</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("end-date")}</strong>
|
<strong>Enddatum</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("rented-items")}</strong>
|
<strong>Ausgeliehene Artikel</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("take-date")}</strong>
|
<strong>Rückgabedatum</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>{t("return-date")}</strong>
|
<strong>Ausleihdatum</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
@@ -182,8 +162,8 @@ const Landingpage: React.FC = () => {
|
|||||||
? loan.loaned_items_name.join(", ")
|
? loan.loaned_items_name.join(", ")
|
||||||
: loan.loaned_items_name}
|
: loan.loaned_items_name}
|
||||||
</Table.Cell>
|
</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>{formatDateTime(loan.take_date)}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
@@ -192,12 +172,12 @@ const Landingpage: React.FC = () => {
|
|||||||
|
|
||||||
{!isLoading && loans.length === 0 && !isError && (
|
{!isLoading && loans.length === 0 && !isError && (
|
||||||
<Text color="gray.500" mt={2}>
|
<Text color="gray.500" mt={2}>
|
||||||
{t("no-loans-found")}
|
Keine Ausleihen vorhanden.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Heading as="h2" size="md" mb={4}>
|
<Heading as="h2" size="md" mb={4}>
|
||||||
{t("all-devices")}
|
Alle Geräte
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
{/* Responsive Grid mit gleich hohen Karten */}
|
{/* Responsive Grid mit gleich hohen Karten */}
|
||||||
@@ -206,34 +186,23 @@ const Landingpage: React.FC = () => {
|
|||||||
<Card.Root
|
<Card.Root
|
||||||
key={device.id}
|
key={device.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
bg={device.in_safe ? "green" : "red"}
|
bg={device.inSafe ? "green" : "red"}
|
||||||
h="full"
|
h="full"
|
||||||
minH="100px"
|
minH="100px"
|
||||||
>
|
>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Heading size="md">
|
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />}
|
||||||
<strong>{device.item_name}</strong>
|
<Heading size="md">{device.item_name}</Heading>
|
||||||
</Heading>
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body color="fg.muted">
|
||||||
<Text>
|
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
|
||||||
<strong>{t("role")}</strong>: {device.can_borrow_role}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<strong>{t("last-borrowed-person")}</strong>:{" "}
|
|
||||||
{device.last_borrowed_person || "N/A"}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<strong>{t("currently-borrowed-by")}</strong>:{" "}
|
|
||||||
{device.currently_borrowing || "N/A"}
|
|
||||||
</Text>
|
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
|
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
|
||||||
<Text fontWeight="medium" color="fg.muted">
|
<Text fontWeight="medium" color="fg.muted">
|
||||||
{t("legend")}:
|
Legende:
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -244,7 +213,8 @@ const Landingpage: React.FC = () => {
|
|||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
>
|
>
|
||||||
<HStack gap={2}>
|
<HStack gap={2}>
|
||||||
<Text>{t("in-locker")}</Text>
|
<LockOpen size={16} />
|
||||||
|
<Text>Im Schließfach</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -256,7 +226,8 @@ const Landingpage: React.FC = () => {
|
|||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
>
|
>
|
||||||
<HStack gap={2}>
|
<HStack gap={2}>
|
||||||
<Text>{t("not-in-locker")}</Text>
|
<Lock size={16} />
|
||||||
|
<Text>Nicht im Schließfach</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -17,14 +17,17 @@ import { useState, useEffect } from "react";
|
|||||||
import { deleteAPKey } from "@/utils/userActions";
|
import { deleteAPKey } from "@/utils/userActions";
|
||||||
import AddAPIKey from "./AddAPIKey";
|
import AddAPIKey from "./AddAPIKey";
|
||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
type Items = {
|
type Items = {
|
||||||
id: number;
|
id: number;
|
||||||
api_key: string;
|
apiKey: string;
|
||||||
entry_name: string;
|
user: string;
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
last_used_at: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const APIKeyTable: React.FC = () => {
|
const APIKeyTable: React.FC = () => {
|
||||||
@@ -53,17 +56,13 @@ const APIKeyTable: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/apiKeys`, {
|
||||||
`${API_BASE}/api/admin/api-data/get-api-keys`,
|
method: "GET",
|
||||||
{
|
headers: {
|
||||||
method: "GET",
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data);
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError("error", "Failed to fetch items", "There is an error");
|
setError("error", "Failed to fetch items", "There is an error");
|
||||||
@@ -124,8 +123,8 @@ const APIKeyTable: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
{/* End action toolbar */}
|
{/* End action toolbar */}
|
||||||
|
|
||||||
<Heading marginBottom={4} size="2xl">
|
<Heading marginBottom={4} size="md">
|
||||||
API Keys
|
Gegenstände
|
||||||
</Heading>
|
</Heading>
|
||||||
{isError && (
|
{isError && (
|
||||||
<MyAlert
|
<MyAlert
|
||||||
@@ -150,55 +149,39 @@ const APIKeyTable: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table.Root
|
<Table.Root size="sm" striped>
|
||||||
size="sm"
|
|
||||||
striped
|
|
||||||
w="100%"
|
|
||||||
// table-layout: auto => Spaltenbreite nach Content; volle Breite nutzen
|
|
||||||
style={{ tableLayout: "auto" }}
|
|
||||||
>
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>#</strong>
|
<strong>#</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>API Key</strong>
|
<strong>API Key</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Name</strong>
|
<strong>Benutzer</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag erstellt am</strong>
|
<strong>Eintrag erstellt am</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Zuletzt benutzt am</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{items.map((item) => (
|
{items.map((apiKey) => (
|
||||||
<Table.Row key={item.id}>
|
<Table.Row key={apiKey.id}>
|
||||||
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
|
<Table.Cell>{apiKey.id}</Table.Cell>
|
||||||
<Table.Cell fontFamily="mono">{item.api_key}</Table.Cell>
|
<Table.Cell>{apiKey.apiKey}</Table.Cell>
|
||||||
<Table.Cell>{item.entry_name}</Table.Cell>
|
<Table.Cell>{apiKey.user}</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
|
||||||
{formatDateTime(item.entry_created_at)}
|
<Table.Cell>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
{!item.last_used_at
|
|
||||||
? "Nie benutzt"
|
|
||||||
: formatDateTime(item.last_used_at)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
deleteAPKey(item.id).then((response) => {
|
deleteAPKey(apiKey.id).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setItems(items.filter((i) => i.id !== item.id));
|
setItems(items.filter((i) => i.id !== apiKey.id));
|
||||||
setError(
|
setError(
|
||||||
"success",
|
"success",
|
||||||
"Gegenstand gelöscht",
|
"Gegenstand gelöscht",
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
InputGroup,
|
|
||||||
Span,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { createAPIentry } from "@/utils/userActions";
|
import { createAPIentry } from "@/utils/userActions";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type AddAPIKeyProps = {
|
type AddAPIKeyProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,8 +12,6 @@ type AddAPIKeyProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
||||||
const [value, setValue] = useState("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<Card.Root maxW="sm">
|
<Card.Root maxW="sm">
|
||||||
@@ -34,26 +23,13 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Stack gap="4" w="full">
|
<Stack gap="4" w="full">
|
||||||
<InputGroup
|
|
||||||
endElement={
|
|
||||||
<Span color="fg.muted" textStyle="xs">
|
|
||||||
{value.length} / {8}
|
|
||||||
</Span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Er muss 8 zahlen lang sein"
|
|
||||||
value={value}
|
|
||||||
id="apiKey"
|
|
||||||
maxLength={8}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.currentTarget.value.slice(0, 8));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Name</Field.Label>
|
<Field.Label>API key</Field.Label>
|
||||||
<Input id="name" type="text" />
|
<Input type="number" id="apiKey" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Benutzer</Field.Label>
|
||||||
|
<Input id="user" type="text" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
@@ -68,14 +44,14 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
|||||||
(
|
(
|
||||||
document.getElementById("apiKey") as HTMLInputElement
|
document.getElementById("apiKey") as HTMLInputElement
|
||||||
)?.value.trim() || "";
|
)?.value.trim() || "";
|
||||||
const name =
|
const user =
|
||||||
(
|
(
|
||||||
document.getElementById("name") as HTMLInputElement
|
document.getElementById("user") as HTMLInputElement
|
||||||
)?.value.trim() || "";
|
)?.value.trim() || "";
|
||||||
|
|
||||||
if (!apiKey || !name) return;
|
if (!apiKey || !user) return;
|
||||||
|
|
||||||
const res = await createAPIentry(apiKey, name);
|
const res = await createAPIentry(apiKey, user);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
alert(
|
alert(
|
||||||
"success",
|
"success",
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Checkbox,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { createUser } from "@/utils/userActions";
|
import { createUser } from "@/utils/userActions";
|
||||||
|
|
||||||
type AddFormProps = {
|
type AddFormProps = {
|
||||||
@@ -20,128 +12,73 @@ type AddFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
||||||
const [admin, setAdmin] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<form
|
<Card.Root maxW="sm">
|
||||||
onSubmit={(e) => {
|
<Card.Header>
|
||||||
e.preventDefault();
|
<Card.Title>Neuen Nutzer erstellen</Card.Title>
|
||||||
}}
|
<Card.Description>
|
||||||
>
|
Füllen Sie das folgende Formular aus, um einen Nutzer zu erstellen.
|
||||||
<Card.Root maxW="sm">
|
</Card.Description>
|
||||||
<Card.Header>
|
</Card.Header>
|
||||||
<Card.Title>Neuen Nutzer erstellen</Card.Title>
|
<Card.Body>
|
||||||
<Card.Description>
|
<Stack gap="4" w="full">
|
||||||
Füllen Sie das folgende Formular aus, um einen Nutzer zu
|
<Field.Root>
|
||||||
erstellen.
|
<Field.Label>Username</Field.Label>
|
||||||
</Card.Description>
|
<Input id="username" />
|
||||||
</Card.Header>
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Password</Field.Label>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Role</Field.Label>
|
||||||
|
<Input id="role" type="number" />
|
||||||
|
</Field.Root>
|
||||||
|
</Stack>
|
||||||
|
</Card.Body>
|
||||||
|
<Card.Footer justifyContent="flex-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={async () => {
|
||||||
|
const username =
|
||||||
|
(
|
||||||
|
document.getElementById("username") as HTMLInputElement
|
||||||
|
)?.value.trim() || "";
|
||||||
|
const password =
|
||||||
|
(document.getElementById("password") as HTMLInputElement)
|
||||||
|
?.value || "";
|
||||||
|
const role = Number(
|
||||||
|
(document.getElementById("role") as HTMLInputElement)?.value
|
||||||
|
);
|
||||||
|
|
||||||
<Card.Body>
|
if (!username || !password || Number.isNaN(role)) return;
|
||||||
<Stack gap="4" w="full">
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Benutzername</Field.Label>
|
|
||||||
<Input id="username" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Passwort</Field.Label>
|
|
||||||
<Input id="password" type="password" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Vorname</Field.Label>
|
|
||||||
<Input id="firstname" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Nachname</Field.Label>
|
|
||||||
<Input id="lastname" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>E-Mail</Field.Label>
|
|
||||||
<Input id="email" type="email" />
|
|
||||||
</Field.Root>
|
|
||||||
|
|
||||||
{/* Kontrollierte Checkbox */}
|
const res = await createUser(username, role, password);
|
||||||
<Checkbox.Root
|
if (res.success) {
|
||||||
checked={admin}
|
alert(
|
||||||
onCheckedChange={(e: any) => setAdmin(Boolean(e?.checked ?? e))}
|
"success",
|
||||||
>
|
"Nutzer erstellt",
|
||||||
<Checkbox.HiddenInput />
|
"Der Nutzer wurde erfolgreich erstellt."
|
||||||
<Checkbox.Control />
|
|
||||||
<Checkbox.Label>Admin</Checkbox.Label>
|
|
||||||
</Checkbox.Root>
|
|
||||||
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Rolle</Field.Label>
|
|
||||||
<Input id="role" type="number" />
|
|
||||||
</Field.Root>
|
|
||||||
</Stack>
|
|
||||||
</Card.Body>
|
|
||||||
<Card.Footer justifyContent="flex-end">
|
|
||||||
<Text>Der Benutzername kann nicht mehr geändert werden.</Text>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
type="submit"
|
|
||||||
onClick={async () => {
|
|
||||||
const username =
|
|
||||||
(
|
|
||||||
document.getElementById("username") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
const password =
|
|
||||||
(document.getElementById("password") as HTMLInputElement)
|
|
||||||
?.value || "";
|
|
||||||
const role = Number(
|
|
||||||
(document.getElementById("role") as HTMLInputElement)?.value
|
|
||||||
);
|
);
|
||||||
const firstname =
|
onClose();
|
||||||
(
|
} else {
|
||||||
document.getElementById("firstname") as HTMLInputElement
|
alert(
|
||||||
)?.value.trim() || "";
|
"error",
|
||||||
const lastname =
|
"Fehler beim Erstellen des Nutzers",
|
||||||
(
|
"Es gab einen Fehler beim Erstellen des Nutzers. Vielleicht gibt es bereits einen Nutzer mit diesem Benutzernamen."
|
||||||
document.getElementById("lastname") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
const email =
|
|
||||||
(
|
|
||||||
document.getElementById("email") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
|
|
||||||
// admin kommt jetzt zuverlässig aus dem State
|
|
||||||
const res = await createUser(
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
firstname,
|
|
||||||
lastname,
|
|
||||||
email,
|
|
||||||
admin
|
|
||||||
);
|
);
|
||||||
|
onClose();
|
||||||
if (res.success) {
|
}
|
||||||
alert(
|
}}
|
||||||
"success",
|
>
|
||||||
"Nutzer erstellt",
|
Erstellen
|
||||||
"Der Nutzer wurde erfolgreich erstellt."
|
</Button>
|
||||||
);
|
</Card.Footer>
|
||||||
onClose();
|
</Card.Root>
|
||||||
} else {
|
|
||||||
alert(
|
|
||||||
"error",
|
|
||||||
"Fehler beim Erstellen des Nutzers",
|
|
||||||
"Es gab einen Fehler beim Erstellen des Nutzers. Vielleicht gibt es bereits einen Nutzer mit diesem Benutzernamen."
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Erstellen
|
|
||||||
</Button>
|
|
||||||
</Card.Footer>
|
|
||||||
</Card.Root>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,19 +25,15 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Stack gap="4" w="full">
|
<Stack gap="4" w="full">
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Gegenstandsname (muss einzigartig sein)</Field.Label>
|
<Field.Label>Gegenstandsname</Field.Label>
|
||||||
<Input id="item_name" placeholder="z.B. Laptop" />
|
<Input id="item_name" placeholder="z.B. Laptop" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Schließfachnummer</Field.Label>
|
|
||||||
<Input id="safe_nr" placeholder="Nummer 1 - 6" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
|
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
|
||||||
<Input
|
<Input
|
||||||
id="can_borrow_role"
|
id="can_borrow_role"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Zahl (1 - 6)"
|
placeholder="Zahl (1 - 4)"
|
||||||
/>
|
/>
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -57,15 +53,10 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
|
|||||||
(document.getElementById("can_borrow_role") as HTMLInputElement)
|
(document.getElementById("can_borrow_role") as HTMLInputElement)
|
||||||
?.value
|
?.value
|
||||||
);
|
);
|
||||||
const safeNrValue = (
|
|
||||||
document.getElementById("safe_nr") as HTMLInputElement
|
|
||||||
)?.value.trim();
|
|
||||||
|
|
||||||
const safeNr = safeNrValue === "" ? null : safeNrValue;
|
|
||||||
|
|
||||||
if (!name || Number.isNaN(role)) return;
|
if (!name || Number.isNaN(role)) return;
|
||||||
|
|
||||||
const res = await createItem(name, role, safeNr);
|
const res = await createItem(name, role);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
alert(
|
alert(
|
||||||
"success",
|
"success",
|
||||||
|
|||||||
@@ -30,19 +30,18 @@ import {
|
|||||||
} from "@/utils/userActions";
|
} from "@/utils/userActions";
|
||||||
import AddItemForm from "./AddItemForm";
|
import AddItemForm from "./AddItemForm";
|
||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
type Items = {
|
type Items = {
|
||||||
id: number;
|
id: number;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
can_borrow_role: string;
|
can_borrow_role: string;
|
||||||
in_safe: boolean;
|
inSafe: boolean;
|
||||||
safe_nr: string;
|
|
||||||
door_key: string;
|
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
entry_updated_at: string;
|
|
||||||
last_borrowed_person: string | null;
|
|
||||||
currently_borrowing: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ItemTable: React.FC = () => {
|
const ItemTable: React.FC = () => {
|
||||||
@@ -67,18 +66,6 @@ const ItemTable: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLockerNumberChange = (id: number, value: string) => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoorKeyChange = (id: number, value: string) => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setError = (
|
const setError = (
|
||||||
status: "error" | "success",
|
status: "error" | "success",
|
||||||
message: string,
|
message: string,
|
||||||
@@ -95,15 +82,12 @@ const ItemTable: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/allItems`, {
|
||||||
`${API_BASE}/api/admin/item-data/all-items`,
|
method: "GET",
|
||||||
{
|
headers: {
|
||||||
method: "GET",
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -165,7 +149,7 @@ const ItemTable: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
{/* End action toolbar */}
|
{/* End action toolbar */}
|
||||||
|
|
||||||
<Heading marginBottom={4} size="2xl">
|
<Heading marginBottom={4} size="md">
|
||||||
Gegenstände
|
Gegenstände
|
||||||
</Heading>
|
</Heading>
|
||||||
{isError && (
|
{isError && (
|
||||||
@@ -191,190 +175,136 @@ const ItemTable: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* make table fill available width, like UserTable */}
|
<Table.Root size="sm" striped>
|
||||||
{!isLoading && (
|
<Table.Header>
|
||||||
<Table.Root
|
<Table.Row>
|
||||||
size="sm"
|
<Table.ColumnHeader>
|
||||||
striped
|
<strong>#</strong>
|
||||||
w="100%"
|
</Table.ColumnHeader>
|
||||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
<Table.ColumnHeader>
|
||||||
>
|
<strong>Gegenstand</strong>
|
||||||
<Table.Header>
|
</Table.ColumnHeader>
|
||||||
<Table.Row>
|
<Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<strong>Ausleih Berechtigung</strong>
|
||||||
<strong>#</strong>
|
</Table.ColumnHeader>
|
||||||
</Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<strong>Im Schließfach</strong>
|
||||||
<strong>Gegenstand</strong>
|
</Table.ColumnHeader>
|
||||||
</Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<strong>Eintrag erstellt am</strong>
|
||||||
<strong>Ausleih Berechtigung</strong>
|
</Table.ColumnHeader>
|
||||||
</Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<strong>Aktionen</strong>
|
||||||
<strong>Im Schließfach</strong>
|
</Table.ColumnHeader>
|
||||||
</Table.ColumnHeader>
|
</Table.Row>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
</Table.Header>
|
||||||
<strong>Schließfachnummer</strong>
|
<Table.Body>
|
||||||
</Table.ColumnHeader>
|
{items.map((item) => (
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.Row key={item.id}>
|
||||||
<strong>Schlüssel</strong>
|
<Table.Cell>{item.id}</Table.Cell>
|
||||||
</Table.ColumnHeader>
|
<Table.Cell>
|
||||||
<Table.ColumnHeader>
|
<Input
|
||||||
<strong>Eintrag erstellt am</strong>
|
onChange={(e) =>
|
||||||
</Table.ColumnHeader>
|
handleItemNameChange(item.id, e.target.value)
|
||||||
<Table.ColumnHeader>
|
}
|
||||||
<strong>Eintrag aktualisiert am</strong>
|
value={item.item_name}
|
||||||
</Table.ColumnHeader>
|
/>
|
||||||
<Table.ColumnHeader>
|
</Table.Cell>
|
||||||
<strong>LaP *</strong>
|
<Table.Cell>
|
||||||
</Table.ColumnHeader>
|
<Input
|
||||||
<Table.ColumnHeader>
|
onChange={(e) =>
|
||||||
<strong>Dav **</strong>
|
handleCanBorrowRoleChange(item.id, e.target.value)
|
||||||
</Table.ColumnHeader>
|
}
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
value={item.can_borrow_role}
|
||||||
<strong>Aktionen</strong>
|
/>
|
||||||
</Table.ColumnHeader>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
changeSafeState(item.id).then(() => setReload(!reload))
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
rounded="full"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
gap={2}
|
||||||
|
variant="ghost"
|
||||||
|
color={item.inSafe ? "green.600" : "red.600"}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={item.inSafe ? "green.300" : "red.300"}
|
||||||
|
_hover={{
|
||||||
|
bg: item.inSafe ? "green.50" : "red.50",
|
||||||
|
borderColor: item.inSafe ? "green.400" : "red.400",
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
shadow: "sm",
|
||||||
|
}}
|
||||||
|
_active={{ transform: "translateY(0)" }}
|
||||||
|
aria-label={
|
||||||
|
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={item.inSafe ? CheckCircle2 : XCircle}
|
||||||
|
boxSize={3.5}
|
||||||
|
mr={2}
|
||||||
|
/>
|
||||||
|
<Text as="span" fontSize="xs" fontWeight="semibold">
|
||||||
|
{item.inSafe ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleEditItems(
|
||||||
|
item.id,
|
||||||
|
item.item_name,
|
||||||
|
item.can_borrow_role
|
||||||
|
).then((response) => {
|
||||||
|
if (response.success) {
|
||||||
|
setError(
|
||||||
|
"success",
|
||||||
|
"Gegenstand erfolgreich bearbeitet!",
|
||||||
|
"Gegenstand " +
|
||||||
|
'"' +
|
||||||
|
item.item_name +
|
||||||
|
'" mit ID ' +
|
||||||
|
item.id +
|
||||||
|
" bearbeitet."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
colorPalette="teal"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
deleteItem(item.id).then((response) => {
|
||||||
|
if (response.success) {
|
||||||
|
setItems(items.filter((i) => i.id !== item.id));
|
||||||
|
setError(
|
||||||
|
"success",
|
||||||
|
"Gegenstand gelöscht",
|
||||||
|
"Der Gegenstand wurde erfolgreich gelöscht."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
colorPalette="red"
|
||||||
|
size="sm"
|
||||||
|
ml={2}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
))}
|
||||||
<Table.Body>
|
</Table.Body>
|
||||||
{items.map((item) => (
|
</Table.Root>
|
||||||
<Table.Row key={item.id}>
|
|
||||||
<Table.Cell>{item.id}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
|
||||||
handleItemNameChange(item.id, e.target.value)
|
|
||||||
}
|
|
||||||
value={item.item_name}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
|
||||||
handleCanBorrowRoleChange(item.id, e.target.value)
|
|
||||||
}
|
|
||||||
value={item.can_borrow_role}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
changeSafeState(item.id).then(() => setReload(!reload))
|
|
||||||
}
|
|
||||||
size="xs"
|
|
||||||
rounded="full"
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
gap={2}
|
|
||||||
variant="ghost"
|
|
||||||
color={item.in_safe ? "green.600" : "red.600"}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={item.in_safe ? "green.300" : "red.300"}
|
|
||||||
_hover={{
|
|
||||||
bg: item.in_safe ? "green.50" : "red.50",
|
|
||||||
borderColor: item.in_safe ? "green.400" : "red.400",
|
|
||||||
transform: "translateY(-1px)",
|
|
||||||
shadow: "sm",
|
|
||||||
}}
|
|
||||||
_active={{ transform: "translateY(0)" }}
|
|
||||||
aria-label={
|
|
||||||
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
as={item.in_safe ? CheckCircle2 : XCircle}
|
|
||||||
boxSize={3.5}
|
|
||||||
mr={2}
|
|
||||||
/>
|
|
||||||
<Text as="span" fontSize="xs" fontWeight="semibold">
|
|
||||||
{item.in_safe ? "Yes" : "No"}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
|
||||||
handleLockerNumberChange(item.id, e.target.value)
|
|
||||||
}
|
|
||||||
value={item.safe_nr}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
|
||||||
handleDoorKeyChange(item.id, e.target.value)
|
|
||||||
}
|
|
||||||
value={item.door_key}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
|
||||||
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
|
|
||||||
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
|
|
||||||
<Table.Cell>{item.currently_borrowing}</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleEditItems(
|
|
||||||
item.id,
|
|
||||||
item.item_name,
|
|
||||||
item.safe_nr,
|
|
||||||
item.door_key,
|
|
||||||
item.can_borrow_role
|
|
||||||
).then((response) => {
|
|
||||||
if (response.success) {
|
|
||||||
setError(
|
|
||||||
"success",
|
|
||||||
"Gegenstand erfolgreich bearbeitet!",
|
|
||||||
"Gegenstand " +
|
|
||||||
'"' +
|
|
||||||
item.item_name +
|
|
||||||
'" mit ID ' +
|
|
||||||
item.id +
|
|
||||||
" bearbeitet."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
colorPalette="teal"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Save />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
deleteItem(item.id).then((response) => {
|
|
||||||
if (response.success) {
|
|
||||||
setItems(items.filter((i) => i.id !== item.id));
|
|
||||||
setError(
|
|
||||||
"success",
|
|
||||||
"Gegenstand gelöscht",
|
|
||||||
"Der Gegenstand wurde erfolgreich gelöscht."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
colorPalette="red"
|
|
||||||
size="sm"
|
|
||||||
ml={2}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
)}
|
|
||||||
<Text>* LaP = Letzte ausleihende Person</Text>
|
|
||||||
<Text>** Dav = Derzeit ausgeliehen von</Text>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import MyAlert from "./myChakra/MyAlert";
|
|||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { Trash2, RefreshCcwDot } from "lucide-react";
|
import { Trash2, RefreshCcwDot } from "lucide-react";
|
||||||
import { deleteLoan } from "@/utils/userActions";
|
import { deleteLoan } from "@/utils/userActions";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
const LoanTable: React.FC = () => {
|
const LoanTable: React.FC = () => {
|
||||||
const [items, setItems] = useState<Loan[]>([]);
|
const [items, setItems] = useState<Loan[]>([]);
|
||||||
@@ -50,23 +54,18 @@ const LoanTable: React.FC = () => {
|
|||||||
returned_date: string;
|
returned_date: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
loaned_items_name: string[];
|
loaned_items_name: string[];
|
||||||
deleted: boolean;
|
|
||||||
note: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/allLoans`, {
|
||||||
`${API_BASE}/api/admin/loan-data/all-loans`,
|
method: "GET",
|
||||||
{
|
headers: {
|
||||||
method: "GET",
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,13 +108,9 @@ const LoanTable: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
{/* End action toolbar */}
|
{/* End action toolbar */}
|
||||||
|
|
||||||
<Heading marginBottom={4} size="2xl">
|
<Heading marginBottom={4} size="md">
|
||||||
Ausleihen
|
Ausleihen
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text>
|
|
||||||
Die Ausleihen die rot sind, wurden gelöscht und sind nur für den Admin
|
|
||||||
sichtbar.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{isError && (
|
{isError && (
|
||||||
<MyAlert
|
<MyAlert
|
||||||
@@ -161,9 +156,6 @@ const LoanTable: React.FC = () => {
|
|||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Ausgeliehene Artikel</strong>
|
<strong>Ausgeliehene Artikel</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Notiz</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
@@ -171,7 +163,7 @@ const LoanTable: React.FC = () => {
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
|
<Table.Row key={item.id}>
|
||||||
<Table.Cell>{item.id}</Table.Cell>
|
<Table.Cell>{item.id}</Table.Cell>
|
||||||
<Table.Cell>{item.username}</Table.Cell>
|
<Table.Cell>{item.username}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
@@ -183,7 +175,6 @@ const LoanTable: React.FC = () => {
|
|||||||
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
|
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
|
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
|
||||||
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
|
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
|
||||||
<Table.Cell>{item.note}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
IconButton,
|
IconButton,
|
||||||
Heading,
|
Heading,
|
||||||
Switch, // neu
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Tooltip } from "@/components/ui/tooltip";
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
import { fetchUserData } from "@/utils/fetcher";
|
import { fetchUserData } from "@/utils/fetcher";
|
||||||
@@ -24,13 +23,9 @@ import ChangePWform from "./ChangePWform";
|
|||||||
type User = {
|
type User = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
password: string;
|
||||||
last_name: string;
|
role: string;
|
||||||
email: string;
|
|
||||||
is_admin: boolean;
|
|
||||||
role: number;
|
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
entry_updated_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserTable: React.FC = () => {
|
const UserTable: React.FC = () => {
|
||||||
@@ -57,20 +52,10 @@ const UserTable: React.FC = () => {
|
|||||||
setIsError(true);
|
setIsError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (userId: number, field: string, value: any) => {
|
const handleInputChange = (userId: number, field: string, value: string) => {
|
||||||
setUsers((prevUsers) =>
|
setUsers((prevUsers) =>
|
||||||
prevUsers.map((user) =>
|
prevUsers.map((user) =>
|
||||||
user.id === userId
|
user.id === userId ? { ...user, [field]: value } : user
|
||||||
? {
|
|
||||||
...user,
|
|
||||||
[field]:
|
|
||||||
field === "role"
|
|
||||||
? Number(value)
|
|
||||||
: field === "is_admin"
|
|
||||||
? value === true || value === "true" || value === 1
|
|
||||||
: value,
|
|
||||||
}
|
|
||||||
: user
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -85,7 +70,7 @@ const UserTable: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchUserData();
|
const data = await fetchUserData();
|
||||||
console.log(data);
|
console.log("user api response", data);
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setUsers(data);
|
setUsers(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -159,7 +144,7 @@ const UserTable: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
{/* End action toolbar */}
|
{/* End action toolbar */}
|
||||||
|
|
||||||
<Heading marginBottom={4} size="2xl">
|
<Heading marginBottom={4} size="md">
|
||||||
Benutzer
|
Benutzer
|
||||||
</Heading>
|
</Heading>
|
||||||
{changePWform && (
|
{changePWform && (
|
||||||
@@ -195,45 +180,25 @@ const UserTable: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Table.Root
|
<Table.Root size="sm" striped>
|
||||||
size="sm"
|
|
||||||
striped
|
|
||||||
w="100%"
|
|
||||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
|
||||||
>
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>#</strong>
|
<strong>#</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Benutzername</strong>
|
<strong>Benutzername</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Vorname</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Nachname</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>E-Mail</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Admin</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
|
||||||
<strong>Passwort ändern</strong>
|
<strong>Passwort ändern</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Rolle</strong>
|
<strong>Rolle</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag erstellt am</strong>
|
<strong>Eintrag erstellt am</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag aktualisiert am</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -241,86 +206,37 @@ const UserTable: React.FC = () => {
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<Table.Row key={user.id}>
|
<Table.Row key={user.id}>
|
||||||
<Table.Cell whiteSpace="nowrap">{user.id}</Table.Cell>
|
<Table.Cell>{user.id}</Table.Cell>
|
||||||
<Table.Cell>{user.username}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
|
||||||
value={user.first_name ?? ""}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleInputChange(user.id, "first_name", e.target.value)
|
handleInputChange(user.id, "username", e.target.value)
|
||||||
}
|
}
|
||||||
|
value={user.username}
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Button onClick={() => handlePasswordChange(user.username)}>
|
||||||
size="sm"
|
|
||||||
value={user.last_name ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange(user.id, "last_name", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
size="sm"
|
|
||||||
value={user.email ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange(user.id, "email", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Switch.Root
|
|
||||||
size="sm"
|
|
||||||
checked={!!user.is_admin}
|
|
||||||
onCheckedChange={(d) =>
|
|
||||||
handleInputChange(user.id, "is_admin", d.checked)
|
|
||||||
}
|
|
||||||
aria-label="Adminrechte umschalten"
|
|
||||||
>
|
|
||||||
<Switch.Control>
|
|
||||||
<Switch.Thumb />
|
|
||||||
</Switch.Control>
|
|
||||||
<Switch.HiddenInput />
|
|
||||||
</Switch.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePasswordChange(user.username)}
|
|
||||||
>
|
|
||||||
Passwort ändern
|
Passwort ändern
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
size="sm"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleInputChange(user.id, "role", e.target.value)
|
handleInputChange(user.id, "role", e.target.value)
|
||||||
}
|
}
|
||||||
value={user.role}
|
value={user.role}
|
||||||
width="70px"
|
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell>
|
||||||
{formatDateTime(user.entry_created_at)}
|
<Table.Cell>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
{formatDateTime(user.entry_updated_at)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleEdit(
|
handleEdit(
|
||||||
user.id,
|
user.id,
|
||||||
user.first_name,
|
user.username,
|
||||||
user.last_name,
|
user.role,
|
||||||
user.email,
|
|
||||||
user.is_admin,
|
|
||||||
Number(user.role)
|
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const API_BASE =
|
|
||||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
|
||||||
import.meta.env.VITE_BACKEND_URL ||
|
|
||||||
"http://localhost:8002";
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const fetchUserData = async () => {
|
export const fetchUserData = async () => {
|
||||||
const response = await fetch(`${API_BASE}/api/admin/user-data/users`, {
|
const response = await fetch(`${API_BASE}/api/allUsers`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export type LoginSuccess = { success: true };
|
export type LoginSuccess = { success: true };
|
||||||
export type LoginFailure = {
|
export type LoginFailure = {
|
||||||
@@ -14,20 +18,12 @@ export const loginFunc = async (
|
|||||||
password: string
|
password: string
|
||||||
): Promise<LoginResult> => {
|
): Promise<LoginResult> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/admin/user-mgmt/login`, {
|
const response = await fetch(`${API_BASE}/api/loginAdmin`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Login failed!",
|
|
||||||
description: "You are not an admin user.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -43,7 +39,6 @@ export const loginFunc = async (
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error logging in:", error);
|
console.error("Error logging in:", error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Login failed!",
|
message: "Login failed!",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const handleDelete = async (userId: number) => {
|
export const handleDelete = async (userId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/user-data/delete-user/${userId}`,
|
`${API_BASE}/api/deleteUser/${userId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -24,28 +28,19 @@ export const handleDelete = async (userId: number) => {
|
|||||||
|
|
||||||
export const handleEdit = async (
|
export const handleEdit = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
first_name: string,
|
username: string,
|
||||||
last_name: string,
|
role: string
|
||||||
email: string,
|
|
||||||
is_admin: boolean,
|
|
||||||
role: number
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/user-data/edit-user/${userId}`,
|
`${API_BASE}/api/editUser/${userId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, role }),
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -61,32 +56,17 @@ export const handleEdit = async (
|
|||||||
export const createUser = async (
|
export const createUser = async (
|
||||||
username: string,
|
username: string,
|
||||||
role: number,
|
role: number,
|
||||||
password: string,
|
password: string
|
||||||
first_name: string,
|
|
||||||
last_name: string,
|
|
||||||
email: string,
|
|
||||||
isAdmin: boolean
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createUser`, {
|
||||||
`${API_BASE}/api/admin/user-data/create-user`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
body: JSON.stringify({ username, role, password }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to create user");
|
throw new Error("Failed to create user");
|
||||||
}
|
}
|
||||||
@@ -99,17 +79,14 @@ export const createUser = async (
|
|||||||
|
|
||||||
export const changePW = async (newPassword: string, username: string) => {
|
export const changePW = async (newPassword: string, username: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/changePWadmin`, {
|
||||||
`${API_BASE}/api/admin/user-data/change-password`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
body: JSON.stringify({ newPassword, username }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({ username, password: newPassword }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to change password");
|
throw new Error("Failed to change password");
|
||||||
}
|
}
|
||||||
@@ -123,7 +100,7 @@ export const changePW = async (newPassword: string, username: string) => {
|
|||||||
export const deleteLoan = async (loanId: number) => {
|
export const deleteLoan = async (loanId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/loan-data/delete-loan/${loanId}`,
|
`${API_BASE}/api/deleteLoan/${loanId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -144,7 +121,7 @@ export const deleteLoan = async (loanId: number) => {
|
|||||||
export const deleteItem = async (itemId: number) => {
|
export const deleteItem = async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/item-data/delete-item/${itemId}`,
|
`${API_BASE}/api/deleteItem/${itemId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -164,27 +141,22 @@ export const deleteItem = async (itemId: number) => {
|
|||||||
|
|
||||||
export const createItem = async (
|
export const createItem = async (
|
||||||
item_name: string,
|
item_name: string,
|
||||||
can_borrow_role: number,
|
can_borrow_role: number
|
||||||
lockerNumber: string | null
|
|
||||||
) => {
|
) => {
|
||||||
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createItem`, {
|
||||||
`${API_BASE}/api/admin/item-data/create-item`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
body: JSON.stringify({ item_name, can_borrow_role }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({ item_name, can_borrow_role, lockerNumber }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message:
|
||||||
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
|
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -197,22 +169,17 @@ export const createItem = async (
|
|||||||
export const handleEditItems = async (
|
export const handleEditItems = async (
|
||||||
itemId: number,
|
itemId: number,
|
||||||
item_name: string,
|
item_name: string,
|
||||||
safe_nr: string | null,
|
|
||||||
door_key: string | null,
|
|
||||||
can_borrow_role: string
|
can_borrow_role: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/updateItemByID`, {
|
||||||
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
body: JSON.stringify({ itemId, item_name, can_borrow_role }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to edit item");
|
throw new Error("Failed to edit item");
|
||||||
}
|
}
|
||||||
@@ -226,9 +193,9 @@ export const handleEditItems = async (
|
|||||||
export const changeSafeState = async (itemId: number) => {
|
export const changeSafeState = async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/item-data/change-safe-state/${itemId}`,
|
`${API_BASE}/api/changeSafeState/${itemId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
@@ -244,19 +211,16 @@ export const changeSafeState = async (itemId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAPIentry = async (apiKey: string, name: string) => {
|
export const createAPIentry = async (apiKey: string, user: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
|
||||||
`${API_BASE}/api/admin/api-data/create-api-key`,
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
"Content-Type": "application/json",
|
||||||
headers: {
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
body: JSON.stringify({ apiKey, user }),
|
||||||
},
|
});
|
||||||
body: JSON.stringify({ apiKey, entryName: name }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -274,7 +238,7 @@ export const createAPIentry = async (apiKey: string, name: string) => {
|
|||||||
export const deleteAPKey = async (apiKeyId: number) => {
|
export const deleteAPKey = async (apiKeyId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/api-data/delete-api-key/${apiKeyId}`,
|
`${API_BASE}/api/deleteAPKey/${apiKeyId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"ignoreDeprecations": "5.0"
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8004
|
EXPOSE 8102
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "backendv2",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "backendv2",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -172,9 +172,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -207,9 +207,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
|
||||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -566,9 +566,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.1.0",
|
"version": "6.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
|
||||||
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
|
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -674,15 +674,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.15.3",
|
"version": "3.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz",
|
||||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
"integrity": "sha512-fD6MLV8XJ1KiNFIF0bS7Msl8eZyhlTDCDl75ajU5SJtpdx9ZPEACulJcqJWr1Y8OYyxsFc4j3+nflpmhxCU5aQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.1",
|
"aws-ssl-profiles": "^1.1.1",
|
||||||
"denque": "^2.1.0",
|
"denque": "^2.1.0",
|
||||||
"generate-function": "^2.3.1",
|
"generate-function": "^2.3.1",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.6.3",
|
||||||
"long": "^5.2.1",
|
"long": "^5.2.1",
|
||||||
"lru.min": "^1.0.0",
|
"lru.min": "^1.0.0",
|
||||||
"named-placeholders": "^1.1.3",
|
"named-placeholders": "^1.1.3",
|
||||||
@@ -693,22 +693,6 @@
|
|||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/named-placeholders": {
|
"node_modules/named-placeholders": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
||||||
@@ -731,9 +715,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.10",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
||||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -791,13 +775,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"engines": {
|
||||||
"type": "opencollective",
|
"node": ">=16"
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -844,34 +827,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||||
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
|
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "3.1.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"iconv-lite": "0.7.0",
|
"iconv-lite": "0.6.3",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.8"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "backendv2",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "server.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
560
backend/routes/api.js
Normal file
560
backend/routes/api.js
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
loginFunc,
|
||||||
|
getItemsFromDatabase,
|
||||||
|
getLoansFromDatabase,
|
||||||
|
getUserLoansFromDatabase,
|
||||||
|
deleteLoanFromDatabase,
|
||||||
|
getBorrowableItemsFromDatabase,
|
||||||
|
createLoanInDatabase,
|
||||||
|
onTake,
|
||||||
|
loginAdmin,
|
||||||
|
onReturn,
|
||||||
|
getAllUsers,
|
||||||
|
deleteUserID,
|
||||||
|
handleEdit,
|
||||||
|
createUser,
|
||||||
|
getAllLoans,
|
||||||
|
getAllItems,
|
||||||
|
deleteItemID,
|
||||||
|
createItem,
|
||||||
|
changeUserPassword,
|
||||||
|
changeUserPasswordFRONTEND,
|
||||||
|
changeInSafeStateV2,
|
||||||
|
updateItemByID,
|
||||||
|
getAllApiKeys,
|
||||||
|
createAPIentry,
|
||||||
|
deleteAPKey,
|
||||||
|
getLoanInfoWithID,
|
||||||
|
} from "../services/database.js";
|
||||||
|
import { authenticate, generateToken } from "../services/tokenService.js";
|
||||||
|
const router = express.Router();
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Nice HTML + text templates for the loan email
|
||||||
|
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||||
|
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||||
|
const itemsList =
|
||||||
|
Array.isArray(items) && items.length
|
||||||
|
? `<ul style="margin:8px 0 0 16px; padding:0;">${items
|
||||||
|
.map((i) => `<li style="margin:4px 0;">${i}</li>`)
|
||||||
|
.join("")}</ul>`
|
||||||
|
: "<span>N/A</span>";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f6f9fc; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827;">
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px; background:${brand}; color:#ffffff;">
|
||||||
|
<h1 style="margin:0; font-size:18px;">Neue Ausleihe erstellt</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<p style="margin:0 0 12px 0;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0; color:#6b7280; width:180px;">Benutzer</td>
|
||||||
|
<td style="padding:8px 0; font-weight:600;">${
|
||||||
|
user || "N/A"
|
||||||
|
}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0; color:#6b7280; vertical-align:top;">Ausgeliehene Gegenstände</td>
|
||||||
|
<td style="padding:8px 0; font-weight:600;">${itemsList}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0; color:#6b7280;">Startdatum</td>
|
||||||
|
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
|
||||||
|
startDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0; color:#6b7280;">Enddatum</td>
|
||||||
|
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
|
||||||
|
endDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0; color:#6b7280;">Erstellt am</td>
|
||||||
|
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
|
||||||
|
createdDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:20px 0 0 0; font-size:14px;">
|
||||||
|
<a href="https://admin.insta.the1s.de/api" style="color:${brand}; text-decoration:underline;" target="_blank" rel="noopener noreferrer">
|
||||||
|
Zur Übersicht aller Ausleihen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0 0; font-size:12px; color:#6b7280;">Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
||||||
|
const itemsText =
|
||||||
|
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
||||||
|
return [
|
||||||
|
"Neue Ausleihe erstellt",
|
||||||
|
"",
|
||||||
|
`Benutzer: ${user || "N/A"}`,
|
||||||
|
`Gegenstände: ${itemsText}`,
|
||||||
|
`Start: ${formatDateTime(startDate)}`,
|
||||||
|
`Ende: ${formatDateTime(endDate)}`,
|
||||||
|
`Erstellt am: ${formatDateTime(createdDate)}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.MAIL_HOST,
|
||||||
|
port: process.env.MAIL_PORT,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: process.env.MAIL_USER,
|
||||||
|
pass: process.env.MAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
||||||
|
to: process.env.MAIL_SENDEES,
|
||||||
|
subject: "Eine neue Ausleihe wurde erstellt!",
|
||||||
|
text: buildLoanEmailText({
|
||||||
|
user,
|
||||||
|
items,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
createdDate,
|
||||||
|
}),
|
||||||
|
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Message sent:", info.messageId);
|
||||||
|
})();
|
||||||
|
console.log("sendMailLoan called");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (value == null) return "N/A";
|
||||||
|
|
||||||
|
const toOut = (d) => {
|
||||||
|
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value instanceof Date) return toOut(value);
|
||||||
|
if (typeof value === "number") return toOut(new Date(value));
|
||||||
|
|
||||||
|
const s = String(value).trim();
|
||||||
|
|
||||||
|
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
|
||||||
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
|
||||||
|
if (m) {
|
||||||
|
const [, y, M, d, h, min] = m;
|
||||||
|
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO or other parseable formats
|
||||||
|
const dObj = new Date(s);
|
||||||
|
if (!isNaN(dObj.getTime())) return toOut(dObj);
|
||||||
|
|
||||||
|
return "N/A";
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const result = await loginFunc(req.body.username, req.body.password);
|
||||||
|
if (result.success) {
|
||||||
|
const token = await generateToken({
|
||||||
|
username: result.data.username,
|
||||||
|
role: result.data.role,
|
||||||
|
});
|
||||||
|
res.status(200).json({ message: "Login successful", token });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ message: "Invalid credentials" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/items", authenticate, async (req, res) => {
|
||||||
|
console.log(req);
|
||||||
|
const result = await getItemsFromDatabase(req.user.role);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch items" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/loans", authenticate, async (req, res) => {
|
||||||
|
const result = await getLoansFromDatabase();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch loans" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/userLoans", authenticate, async (req, res) => {
|
||||||
|
const result = await getUserLoansFromDatabase(req.user.username);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch user loans" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
|
||||||
|
const loanId = req.params.id;
|
||||||
|
const result = await deleteLoanFromDatabase(loanId);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Loan deleted successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to delete loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/borrowableItems", authenticate, async (req, res) => {
|
||||||
|
const { startDate, endDate } = req.body || {};
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "startDate and endDate are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getBorrowableItemsFromDatabase(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
req.user.role
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
// return the array directly for consistency with /items
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Failed to fetch borrowable items" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/takeLoan/:id", authenticate, async (req, res) => {
|
||||||
|
const loanId = req.params.id;
|
||||||
|
const result = await onTake(loanId);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Loan taken successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to take loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/returnLoan/:id", authenticate, async (req, res) => {
|
||||||
|
const loanId = req.params.id;
|
||||||
|
const result = await onReturn(loanId);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Loan returned successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to return loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createLoan", authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items, startDate, endDate } = req.body || {};
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ message: "Items array is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dates are not provided, default to now .. +7 days
|
||||||
|
const start =
|
||||||
|
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
const end =
|
||||||
|
endDate ??
|
||||||
|
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 19)
|
||||||
|
.replace("T", " ");
|
||||||
|
|
||||||
|
// Coerce item IDs to numbers and filter invalids
|
||||||
|
const itemIds = items
|
||||||
|
.map((v) => Number(v))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
|
||||||
|
if (itemIds.length === 0) {
|
||||||
|
return res.status(400).json({ message: "No valid item IDs provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createLoanInDatabase(
|
||||||
|
req.user.username,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
itemIds
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||||
|
console.log(mailInfo);
|
||||||
|
sendMailLoan(
|
||||||
|
mailInfo.data.username,
|
||||||
|
mailInfo.data.loaned_items_name,
|
||||||
|
mailInfo.data.start_date,
|
||||||
|
mailInfo.data.end_date,
|
||||||
|
mailInfo.data.created_at
|
||||||
|
);
|
||||||
|
return res.status(201).json({
|
||||||
|
message: "Loan created successfully",
|
||||||
|
loanId: result.data.id,
|
||||||
|
loanCode: result.data.loan_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === "CONFLICT") {
|
||||||
|
return res
|
||||||
|
.status(409)
|
||||||
|
.json({ message: "Items not available in the selected period" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === "BAD_REQUEST") {
|
||||||
|
return res.status(400).json({ message: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ message: "Failed to create loan" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("createLoan error:", err);
|
||||||
|
return res.status(500).json({ message: "Failed to create loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/changePassword", authenticate, async (req, res) => {
|
||||||
|
const { oldPassword, newPassword } = req.body || {};
|
||||||
|
const username = req.user.username;
|
||||||
|
const result = await changeUserPasswordFRONTEND(
|
||||||
|
username,
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Password changed successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to change password" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin panel functions
|
||||||
|
|
||||||
|
router.post("/loginAdmin", async (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Username and password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await loginAdmin(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
const token = await generateToken({
|
||||||
|
username: result.data.username,
|
||||||
|
role: result.data.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Login successful",
|
||||||
|
first_name: result.data.first_name,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ message: "Invalid credentials" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allUsers", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllUsers();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch users" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const result = await deleteUserID(userId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "User deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/verifyToken", authenticate, async (req, res) => {
|
||||||
|
res.status(200).json({ message: "Token is valid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/editUser/:id", authenticate, async (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { username, role } = req.body || {};
|
||||||
|
const result = await handleEdit(userId, username, role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "User edited successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to edit user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createUser", authenticate, async (req, res) => {
|
||||||
|
const { username, role, password } = req.body || {};
|
||||||
|
const result = await createUser(username, role, password);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "User created successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allLoans", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllLoans();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch loans" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allItems", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllItems();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch items" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
|
||||||
|
const itemId = req.params.id;
|
||||||
|
const result = await deleteItemID(itemId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Item deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createItem", authenticate, async (req, res) => {
|
||||||
|
const { item_name, can_borrow_role } = req.body || {};
|
||||||
|
const result = await createItem(item_name, can_borrow_role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "Item created successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/changePWadmin", authenticate, async (req, res) => {
|
||||||
|
const newPassword = req.body.newPassword;
|
||||||
|
if (!newPassword) {
|
||||||
|
return res.status(400).json({ message: "New password is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await changeUserPassword(req.body.username, newPassword);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Password changed successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to change password" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateItemByID", authenticate, async (req, res) => {
|
||||||
|
const role = req.body.can_borrow_role;
|
||||||
|
const itemId = req.body.itemId;
|
||||||
|
const item_name = req.body.item_name;
|
||||||
|
const result = await updateItemByID(itemId, item_name, role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Item updated successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to update item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
|
||||||
|
const itemId = req.params.itemId;
|
||||||
|
const result = await changeInSafeStateV2(itemId);
|
||||||
|
if (result.success) {
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "Item safe state updated successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to update item safe state" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/apiKeys", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllApiKeys();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch API keys" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
|
||||||
|
const apiKeyId = req.params.id;
|
||||||
|
const result = await deleteAPKey(apiKeyId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "API key deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete API key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createAPIentry", authenticate, async (req, res) => {
|
||||||
|
const apiKey = req.body.apiKey;
|
||||||
|
const user = req.body.user;
|
||||||
|
if (!apiKey || !user) {
|
||||||
|
return res.status(400).json({ message: "API key and user are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure apiKey is a number
|
||||||
|
const apiKeyNum = Number(apiKey);
|
||||||
|
if (!Number.isFinite(apiKeyNum)) {
|
||||||
|
return res.status(400).json({ message: "API key must be a number" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createAPIentry(apiKeyNum, user);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "API key created successfully" });
|
||||||
|
}
|
||||||
|
if (result.code === "DUPLICATE") {
|
||||||
|
return res.status(409).json({ message: "API key already exists" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create API key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/apiKeys/validate/:key", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawKey = req.params.key;
|
||||||
|
const result = await getAllApiKeys();
|
||||||
|
if (!result.success || !Array.isArray(result.data)) {
|
||||||
|
return res.status(500).json({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = result.data.some((entry) => {
|
||||||
|
const val = String(
|
||||||
|
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
|
||||||
|
);
|
||||||
|
return val === String(rawKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ valid: isValid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("validate api key error:", err);
|
||||||
|
return res.status(500).json({ valid: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
133
backend/routes/apiV2.js
Normal file
133
backend/routes/apiV2.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import express from "express";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import {
|
||||||
|
getItemsFromDatabaseV2,
|
||||||
|
changeInSafeStateV2,
|
||||||
|
setTakeDateV2,
|
||||||
|
setReturnDateV2,
|
||||||
|
getLoanByCodeV2,
|
||||||
|
getAllLoansV2,
|
||||||
|
getAPIkey,
|
||||||
|
} from "../services/database.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function validateAPIKey(apiKey) {
|
||||||
|
try {
|
||||||
|
if (!apiKey) return false;
|
||||||
|
const result = await getAPIkey();
|
||||||
|
if (!result?.success || !Array.isArray(result.data)) return false;
|
||||||
|
return result.data.some((row) => String(row.apiKey) === String(apiKey));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("validateAPIKey error:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a guard that returns Access Denied instead of hanging
|
||||||
|
const apiKeyGuard = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = req.params.key;
|
||||||
|
if (!key) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ message: "Access denied: missing API key" });
|
||||||
|
}
|
||||||
|
const ok = await validateAPIKey(key);
|
||||||
|
if (!ok) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ message: "Access denied: invalid API key" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("apiKeyGuard error:", e);
|
||||||
|
res.status(500).json({ message: "Internal server error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route for API to get ALL items from the database
|
||||||
|
router.get("/items/:key", apiKeyGuard, async (req, res) => {
|
||||||
|
const result = await getItemsFromDatabaseV2();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch items" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to control the position of an item
|
||||||
|
router.post(
|
||||||
|
"/controlInSafe/:key/:itemId/:state",
|
||||||
|
apiKeyGuard,
|
||||||
|
async (req, res) => {
|
||||||
|
const itemId = req.params.itemId;
|
||||||
|
const state = req.params.state;
|
||||||
|
|
||||||
|
if (state === "1" || state === "0") {
|
||||||
|
const result = await changeInSafeStateV2(itemId, state);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to update item state" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ message: "Invalid state value" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route for API to get a loan by its code
|
||||||
|
router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loan_code = req.params.loan_code;
|
||||||
|
const result = await getLoanByCodeV2(loan_code);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ message: "Loan not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to set the return date by the loan code
|
||||||
|
router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loanCode = req.params.loan_code;
|
||||||
|
const result = await setReturnDateV2(loanCode);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to set return date" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to set the take away date by the loan code
|
||||||
|
router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loanCode = req.params.loan_code;
|
||||||
|
const result = await setTakeDateV2(loanCode);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to set take date" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
|
||||||
|
router.get("/allLoans", async (req, res) => {
|
||||||
|
const result = await getAllLoansV2();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch loans" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to get ALL items from the database (only for landingpage)
|
||||||
|
router.get("/allItems", async (req, res) => {
|
||||||
|
const result = await getItemsFromDatabaseV2();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch items" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
99
backend/scheme.sql
Normal file
99
backend/scheme.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- All necessary tables for the borrowing system
|
||||||
|
|
||||||
|
-- IMPORTANT: You need mySQL version 8.0 or newer!
|
||||||
|
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`role` int DEFAULT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `admins` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`first_name` varchar(255) NOT NULL,
|
||||||
|
`last_name` varchar(255) NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `loans` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`loan_code` int NOT NULL,
|
||||||
|
`start_date` timestamp NOT NULL,
|
||||||
|
`end_date` timestamp NOT NULL,
|
||||||
|
`take_date` timestamp NULL DEFAULT NULL,
|
||||||
|
`returned_date` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
|
||||||
|
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `loan_code` (`loan_code`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `items` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_name` varchar(255) NOT NULL,
|
||||||
|
`can_borrow_role` INT NOT NULL,
|
||||||
|
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `item_name` (`item_name`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `lockers` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`item` varchar(255) NOT NULL,
|
||||||
|
`locker_number` int NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `item` (`item`),
|
||||||
|
UNIQUE KEY `locker_number` (`locker_number`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `apiKeys` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`apiKey` int NOT NULL UNIQUE,
|
||||||
|
`user` VARCHAR(255) NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
|
||||||
|
('DJI 1er Mikro', 4, 1),
|
||||||
|
('DJI 2er Mikro 1', 4, 1),
|
||||||
|
('DJI 2er Mikro 2', 4, 1),
|
||||||
|
('Rode Richt Mikrofon', 2, 1),
|
||||||
|
('Kamera Stativ', 1, 0),
|
||||||
|
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
|
||||||
|
('MacBook inkl. Adapter', 2, 0),
|
||||||
|
('SD Karten', 3, 0),
|
||||||
|
('Kameragimbal', 1, 0),
|
||||||
|
('ATEM MINI PRO', 1, 1),
|
||||||
|
('Handygimbal', 4, 0),
|
||||||
|
('Kameralüfter', 1, 1),
|
||||||
|
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
|
||||||
|
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
|
||||||
|
|
||||||
|
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
|
||||||
|
('DJI 1er Mikro', 1),
|
||||||
|
('DJI 2er Mikro 1', 2),
|
||||||
|
('DJI 2er Mikro 2', 3),
|
||||||
|
('Rode Richt Mikrofon', 4),
|
||||||
|
('Kamera Stativ', 5),
|
||||||
|
('SONY Kamera - inkl. Akkus und Objektiv', 6),
|
||||||
|
('MacBook inkl. Adapter', 7),
|
||||||
|
('SD Karten', 8),
|
||||||
|
('Kameragimbal', 9),
|
||||||
|
('ATEM MINI PRO', 10),
|
||||||
|
('Handygimbal', 11),
|
||||||
|
('Kameralüfter', 12),
|
||||||
|
('Kleine Kamera 1 - inkl. Objektiv', 13),
|
||||||
|
('Kleine Kamera 2 - inkl. Objektiv', 14);
|
||||||
32
backend/server.js
Normal file
32
backend/server.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import env from "dotenv";
|
||||||
|
import apiRouter from "./routes/api.js";
|
||||||
|
import apiRouterV2 from "./routes/apiV2.js";
|
||||||
|
env.config();
|
||||||
|
const app = express();
|
||||||
|
const port = 8102;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
// Increase body size limits to support large CSV JSON payloads
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
app.set("view engine", "ejs");
|
||||||
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
|
||||||
|
app.use("/api", apiRouter);
|
||||||
|
app.use("/apiV2", apiRouterV2);
|
||||||
|
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.render("index.ejs");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server is running on port: ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// error handling code
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
// Log the error stack and send a generic error response
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).send("Something broke!");
|
||||||
|
});
|
||||||
537
backend/services/database.js
Normal file
537
backend/services/database.js
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import mysql from "mysql2";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = mysql
|
||||||
|
.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
})
|
||||||
|
.promise();
|
||||||
|
|
||||||
|
export const loginFunc = async (username, password) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT * FROM users WHERE username = ? AND password = ?",
|
||||||
|
[username, password]
|
||||||
|
);
|
||||||
|
if (result.length > 0) return { success: true, data: result[0] };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemsFromDatabaseV2 = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM items;");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoanByCodeV2 = async (loan_code) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT * FROM loans WHERE loan_code = ?;",
|
||||||
|
[loan_code]
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
return { success: true, data: result[0] };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeInSafeStateV2 = async (itemId) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
|
||||||
|
[itemId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setReturnDateV2 = async (loanCode) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setTakeDateV2 = async (loanCode) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemsFromDatabase = async (role) => {
|
||||||
|
const sql =
|
||||||
|
role == 0
|
||||||
|
? "SELECT * FROM items;"
|
||||||
|
: "SELECT * FROM items WHERE can_borrow_role >= ?";
|
||||||
|
const params = role == 0 ? [] : [role];
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoansFromDatabase = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM loans;");
|
||||||
|
return { success: true, data: rows.length > 0 ? rows : null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserLoansFromDatabase = async (username) => {
|
||||||
|
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
|
||||||
|
username,
|
||||||
|
]);
|
||||||
|
if (result.length > 0) {
|
||||||
|
return { success: true, data: result };
|
||||||
|
} else if (result.length == 0) {
|
||||||
|
return { success: true, data: "No loans found for this user" };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteLoanFromDatabase = async (loanId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
|
||||||
|
loanId,
|
||||||
|
]);
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBorrowableItemsFromDatabase = async (
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
role = 0
|
||||||
|
) => {
|
||||||
|
// Overlap if: loan.start < end AND effective_end > start
|
||||||
|
// effective_end is returned_date if set, otherwise end_date
|
||||||
|
const hasRoleFilter = Number(role) > 0;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT i.*
|
||||||
|
FROM items i
|
||||||
|
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM loans l
|
||||||
|
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
||||||
|
WHERE jt.item_id = i.id
|
||||||
|
AND l.start_date < ?
|
||||||
|
AND COALESCE(l.returned_date, l.end_date) > ?
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = hasRoleFilter
|
||||||
|
? [role, endDate, startDate]
|
||||||
|
: [endDate, startDate];
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoanInfoWithID = async (loanId) => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
|
||||||
|
loanId,
|
||||||
|
]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows[0] };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLoanInDatabase = async (
|
||||||
|
username,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
itemIds
|
||||||
|
) => {
|
||||||
|
if (!username)
|
||||||
|
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
|
||||||
|
if (!Array.isArray(itemIds) || itemIds.length === 0)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "No items provided",
|
||||||
|
};
|
||||||
|
if (!startDate || !endDate)
|
||||||
|
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
if (
|
||||||
|
!(start instanceof Date) ||
|
||||||
|
isNaN(start.getTime()) ||
|
||||||
|
!(end instanceof Date) ||
|
||||||
|
isNaN(end.getTime()) ||
|
||||||
|
start >= end
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Invalid date range",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// Ensure all items exist and collect names
|
||||||
|
const [itemsRows] = await conn.query(
|
||||||
|
"SELECT id, item_name FROM items WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
if (!itemsRows || itemsRows.length !== itemIds.length) {
|
||||||
|
await conn.rollback();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "One or more items not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const itemNames = itemIds
|
||||||
|
.map(
|
||||||
|
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Check availability (no overlap with existing loans)
|
||||||
|
const [confRows] = await conn.query(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS conflicts
|
||||||
|
FROM loans l
|
||||||
|
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
||||||
|
ON TRUE
|
||||||
|
WHERE jt.item_id IN (?)
|
||||||
|
AND l.start_date < ?
|
||||||
|
AND COALESCE(l.returned_date, l.end_date) > ?
|
||||||
|
`,
|
||||||
|
[itemIds, end, start]
|
||||||
|
);
|
||||||
|
if (confRows?.[0]?.conflicts > 0) {
|
||||||
|
await conn.rollback();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "One or more items are not available in the selected period",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique loan_code (retry a few times)
|
||||||
|
let loanCode = null;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
|
||||||
|
const [exists] = await conn.query(
|
||||||
|
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
||||||
|
[candidate]
|
||||||
|
);
|
||||||
|
if (exists.length === 0) {
|
||||||
|
loanCode = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!loanCode) {
|
||||||
|
await conn.rollback();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SERVER_ERROR",
|
||||||
|
message: "Failed to generate unique loan code",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert loan
|
||||||
|
const [insertRes] = await conn.query(
|
||||||
|
`
|
||||||
|
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
|
||||||
|
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
username,
|
||||||
|
loanCode,
|
||||||
|
// Use DATETIME/TIMESTAMP friendly format
|
||||||
|
new Date(start).toISOString().slice(0, 19).replace("T", " "),
|
||||||
|
new Date(end).toISOString().slice(0, 19).replace("T", " "),
|
||||||
|
JSON.stringify(itemIds.map((n) => Number(n))),
|
||||||
|
JSON.stringify(itemNames),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: insertRes.insertId,
|
||||||
|
loan_code: loanCode,
|
||||||
|
username,
|
||||||
|
start_date: start,
|
||||||
|
end_date: end,
|
||||||
|
items: itemIds,
|
||||||
|
item_names: itemNames,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
console.error("createLoanInDatabase error:", err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SERVER_ERROR",
|
||||||
|
message: "Failed to create loan",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// These functions are only temporary, and will be deleted when the full bin is set up.
|
||||||
|
|
||||||
|
export const onTake = async (loanId) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET take_date = NOW() WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onReturn = async (loanId) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginAdmin = async (username, password) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT * FROM admins WHERE username = ? AND password = ?",
|
||||||
|
[username, password]
|
||||||
|
);
|
||||||
|
if (result.length > 0) return { success: true, data: result[0] };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsers = async () => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT id, username, role, entry_created_at FROM users"
|
||||||
|
);
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserID = async (userId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEdit = async (userId, username, role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET username = ?, role = ? WHERE id = ?",
|
||||||
|
[username, role, userId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (username, role, password) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
|
||||||
|
[username, role, password]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoans = async () => {
|
||||||
|
const [result] = await pool.query("SELECT * FROM loans");
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllItems = async () => {
|
||||||
|
const [result] = await pool.query("SELECT * FROM items");
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItemID = async (itemId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createItem = async (item_name, can_borrow_role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
|
||||||
|
[item_name, can_borrow_role]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeUserPassword = async (username, newPassword) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET password = ? WHERE username = ?",
|
||||||
|
[newPassword, username]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeUserPasswordFRONTEND = async (
|
||||||
|
username,
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET password = ? WHERE username = ? AND password = ?",
|
||||||
|
[newPassword, username, oldPassword]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE items SET item_name = ?, can_borrow_role = ? WHERE id = ?",
|
||||||
|
[item_name, can_borrow_role, itemId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoansV2 = async () => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, username, start_date, end_date, loaned_items_name, returned_date, take_date FROM loans"
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllApiKeys = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM apiKeys");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAPIentry = async (apiKey, user) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
|
||||||
|
[apiKey, user]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAPKey = async (apiKeyId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
|
||||||
|
apiKeyId,
|
||||||
|
]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAPIkey = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
26
backend/services/tokenService.js
Normal file
26
backend/services/tokenService.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import env from "dotenv";
|
||||||
|
env.config();
|
||||||
|
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
|
||||||
|
|
||||||
|
export async function generateToken(payload) {
|
||||||
|
const newToken = await new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("2h") // Token valid for 2 hours
|
||||||
|
.sign(secret);
|
||||||
|
console.log("Generated token: ", newToken);
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticate(req, res, next) {
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
const token = authHeader && authHeader.split(" ")[1]; // Bearer <token>
|
||||||
|
|
||||||
|
if (token == null) return res.sendStatus(401); // No token present
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, secret);
|
||||||
|
req.user = payload;
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"backend-info": {
|
|
||||||
"version": "v2.0.1"
|
|
||||||
},
|
|
||||||
"frontend-info": {
|
|
||||||
"version": "v2.0"
|
|
||||||
},
|
|
||||||
"admin-panel-info": {
|
|
||||||
"version": "v1.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
getAllApiKeys,
|
|
||||||
createAPIentry,
|
|
||||||
deleteAPKey,
|
|
||||||
} from "./database/apiDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/get-api-keys", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllApiKeys();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve API keys" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/create-api-key", authenticateAdmin, async (req, res) => {
|
|
||||||
const apiKey = req.body.apiKey;
|
|
||||||
const entryName = req.body.entryName;
|
|
||||||
const result = await createAPIentry(apiKey, entryName);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json({ message: "API key created successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to create API key" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-api-key/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const apiKeyId = req.params.id;
|
|
||||||
const result = await deleteAPKey(apiKeyId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "API key deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete API key" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllApiKeys = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM apiKeys");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createAPIentry = async (apiKey, entryName) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO apiKeys (api_key, entry_name) VALUES (?, ?)",
|
|
||||||
[apiKey, entryName]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAPKey = async (apiKeyId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
|
|
||||||
apiKeyId,
|
|
||||||
]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllItems = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM items");
|
|
||||||
if (result.length > 0) return { success: true, data: result };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteItemById = async (itemId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr) VALUES (?, ?, ?, ?)",
|
|
||||||
[item_name, can_borrow_role, true, lockerNumber]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editItemById = async (
|
|
||||||
itemId,
|
|
||||||
item_name,
|
|
||||||
can_borrow_role,
|
|
||||||
safe_nr,
|
|
||||||
door_key
|
|
||||||
) => {
|
|
||||||
let newSafeNr;
|
|
||||||
if (safe_nr === null || safe_nr === "") {
|
|
||||||
newSafeNr = null;
|
|
||||||
} else {
|
|
||||||
newSafeNr = safe_nr;
|
|
||||||
}
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
|
|
||||||
[item_name, can_borrow_role, newSafeNr, door_key, itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changeSafeState = async (itemId) => {
|
|
||||||
const currentState = await pool.query(
|
|
||||||
"SELECT in_safe FROM items WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (currentState[0].length === 0) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState[0][0].in_safe) {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = false WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentState[0][0].in_safe) {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = true WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllLoans = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans");
|
|
||||||
return { success: true, data: rows };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteLoanById = async (loanId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const createUser = async (
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name
|
|
||||||
) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[username, role, password, isAdmin, email, first_name, last_name]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteUserById = async (userId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (userId, newPassword) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE id = ?",
|
|
||||||
[newPassword, userId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editUserById = async (
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
|
|
||||||
[first_name, last_name, role, email, is_admin, userId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllUsers = async () => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users"
|
|
||||||
);
|
|
||||||
if (result.length > 0) return { success: true, data: result };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUserById = async (userId) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
return { success: true, data: rows[0] };
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const loginAdmin = async (username, password) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, is_admin FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { success: false, reason: "invalid_credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = rows[0];
|
|
||||||
if (!user.is_admin) {
|
|
||||||
return { success: false, reason: "not_admin" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: user };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const executeQuery = async (query, password, username) => {
|
|
||||||
let verified = false;
|
|
||||||
const [user] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
if (user.length > 0 && user[0].is_admin) {
|
|
||||||
verified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
const [result] = await pool.query(`${query}`);
|
|
||||||
return { success: true, data: result };
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
editItemById,
|
|
||||||
getAllItems,
|
|
||||||
deleteItemById,
|
|
||||||
createItem,
|
|
||||||
changeSafeState,
|
|
||||||
} from "./database/itemDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/all-items", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllItems();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve items" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-item/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const result = await deleteItemById(itemId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Item deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete item" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/create-item", authenticateAdmin, async (req, res) => {
|
|
||||||
const { item_name, can_borrow_role, lockerNumber } = req.body;
|
|
||||||
const result = await createItem(item_name, can_borrow_role, lockerNumber);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json({ message: "Item created successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to create item" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/edit-item/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const { item_name, can_borrow_role, safe_nr, door_key } = req.body;
|
|
||||||
|
|
||||||
const result = await editItemById(
|
|
||||||
itemId,
|
|
||||||
item_name,
|
|
||||||
can_borrow_role,
|
|
||||||
safe_nr,
|
|
||||||
door_key
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Item edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit item" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-safe-state/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const result = await changeSafeState(itemId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Safe state changed successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to change safe state" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
deleteLoanById,
|
|
||||||
getAllLoans,
|
|
||||||
} from "./database/loanDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/all-loans", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllLoans();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve loans" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-loan/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const loanId = req.params.id;
|
|
||||||
const result = await deleteLoanById(loanId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Loan deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete loan" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
deleteUserById,
|
|
||||||
editUserById,
|
|
||||||
changePassword,
|
|
||||||
getAllUsers,
|
|
||||||
getUserById,
|
|
||||||
} from "./database/userDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/create-user", authenticateAdmin, async (req, res) => {
|
|
||||||
const username = req.body.username;
|
|
||||||
const role = req.body.role;
|
|
||||||
const password = req.body.password;
|
|
||||||
const isAdmin = req.body.isAdmin;
|
|
||||||
const email = req.body.email;
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const result = await createUser(
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json({ message: "User created successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to create user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const userId = req.params.id;
|
|
||||||
const result = await deleteUserById(userId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const role = req.body.role;
|
|
||||||
const email = req.body.email;
|
|
||||||
const userId = req.params.id;
|
|
||||||
const is_admin = req.body.is_admin;
|
|
||||||
|
|
||||||
const result = await editUserById(
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-password", authenticateAdmin, async (req, res) => {
|
|
||||||
const username = req.body.username;
|
|
||||||
const password = req.body.password;
|
|
||||||
|
|
||||||
const result = await changePassword(username, password);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Password reset successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to reset password" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const userId = req.params.id;
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const role = req.body.role;
|
|
||||||
const email = req.body.email;
|
|
||||||
const is_admin = req.body.is_admin;
|
|
||||||
|
|
||||||
const result = await editUserById(
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/users", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllUsers();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve users" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getUserById(req.params.id);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ user: result.data });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
generateToken,
|
|
||||||
authenticateAdmin,
|
|
||||||
} from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import { loginAdmin, executeQuery } from "./database/userMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/login", async (req, res) => {
|
|
||||||
const { username, password } = req.body || {};
|
|
||||||
if (!username || !password) {
|
|
||||||
return res.status(400).json({ message: "Missing username or password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await loginAdmin(username, password);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const token = await generateToken({
|
|
||||||
username: result.data.username,
|
|
||||||
first_name: result.data.first_name,
|
|
||||||
last_name: result.data.last_name,
|
|
||||||
admin: result.data.is_admin,
|
|
||||||
});
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Login erfolgreich",
|
|
||||||
token,
|
|
||||||
first_name: result.data.first_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.reason === "not_admin") {
|
|
||||||
return res.status(403).json({ message: "Du bist kein Admin" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(401).json({ message: "Ungültige Anmeldedaten" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/verify-token", authenticateAdmin, async (req, res) => {
|
|
||||||
return res.status(200).json({ message: "Token is valid" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/database-query", authenticateAdmin, async (req, res) => {
|
|
||||||
const query = req.body.query;
|
|
||||||
const password = req.body.password;
|
|
||||||
const username = req.body.username;
|
|
||||||
|
|
||||||
const result = await executeQuery(query, password, username);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getItemsFromDatabaseV2 = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result[0] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changeInSafeStateV2 = async (itemId) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setReturnDateV2 = async (loanCode) => {
|
|
||||||
const [items] = await pool.query(
|
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) return { success: false };
|
|
||||||
|
|
||||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
|
||||||
? items[0].loaned_items_id
|
|
||||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
|
||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
|
|
||||||
[owner[0].username, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setTakeDateV2 = async (loanCode) => {
|
|
||||||
const [items] = await pool.query(
|
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) return { success: false };
|
|
||||||
|
|
||||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
|
||||||
? items[0].loaned_items_id
|
|
||||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
|
||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
|
|
||||||
[owner[0].username, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllLoansV2 = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM loans;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const openDoor = async (doorKey) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
|
||||||
[doorKey]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
const [changeItemSate] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
|
||||||
[result[0].id]
|
|
||||||
);
|
|
||||||
if (changeItemSate.affectedRows > 0) {
|
|
||||||
return { success: true, data: result[0] };
|
|
||||||
} else {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
import {
|
|
||||||
getItemsFromDatabaseV2,
|
|
||||||
changeInSafeStateV2,
|
|
||||||
setTakeDateV2,
|
|
||||||
setReturnDateV2,
|
|
||||||
getLoanByCodeV2,
|
|
||||||
openDoor,
|
|
||||||
} from "./api.database.js";
|
|
||||||
|
|
||||||
// Route for API to get all items from the database
|
|
||||||
router.get("/items/:key", authenticate, async (req, res) => {
|
|
||||||
const result = await getItemsFromDatabaseV2();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route for API to control the safe state of an item
|
|
||||||
router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
|
|
||||||
const itemId = req.params.itemId;
|
|
||||||
|
|
||||||
const result = await changeInSafeStateV2(itemId);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to update item state" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route for API to get a loan by its code
|
|
||||||
router.get(
|
|
||||||
"/get-loan-by-code/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loan_code = req.params.loan_code;
|
|
||||||
const result = await getLoanByCodeV2(loan_code);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ message: "Loan not found" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to set the return date by the loan code
|
|
||||||
router.post(
|
|
||||||
"/set-return-date/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loanCode = req.params.loan_code;
|
|
||||||
const result = await setReturnDateV2(loanCode);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to set return date" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to set the take away date by the loan code
|
|
||||||
router.post(
|
|
||||||
"/set-take-date/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loanCode = req.params.loan_code;
|
|
||||||
const result = await setTakeDateV2(loanCode);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to set take date" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to open a door
|
|
||||||
router.get("/open-door/:key/:doorKey", authenticate, async (req, res) => {
|
|
||||||
const doorKey = req.params.doorKey;
|
|
||||||
|
|
||||||
const result = await openDoor(doorKey);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to open door" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const createLoanInDatabase = async (
|
|
||||||
username,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
note,
|
|
||||||
itemIds
|
|
||||||
) => {
|
|
||||||
if (!username)
|
|
||||||
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
|
|
||||||
if (!Array.isArray(itemIds) || itemIds.length === 0)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "No items provided",
|
|
||||||
};
|
|
||||||
if (!startDate || !endDate)
|
|
||||||
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
|
|
||||||
|
|
||||||
const start = new Date(startDate);
|
|
||||||
const end = new Date(endDate);
|
|
||||||
if (
|
|
||||||
!(start instanceof Date) ||
|
|
||||||
isNaN(start.getTime()) ||
|
|
||||||
!(end instanceof Date) ||
|
|
||||||
isNaN(end.getTime()) ||
|
|
||||||
start >= end
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Invalid date range",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
// Ensure all items exist and collect names + lockers
|
|
||||||
const [itemsRows] = await conn.query(
|
|
||||||
"SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
|
|
||||||
[itemIds]
|
|
||||||
);
|
|
||||||
if (!itemsRows || itemsRows.length !== itemIds.length) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "One or more items not found",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemNames = itemIds
|
|
||||||
.map(
|
|
||||||
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
|
|
||||||
)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
// Build lockers array (unique, only 2-digit numbers from safe_nr)
|
|
||||||
const lockers = [
|
|
||||||
...new Set(
|
|
||||||
itemsRows
|
|
||||||
.map((r) => r.safe_nr)
|
|
||||||
.filter(
|
|
||||||
(sn) =>
|
|
||||||
sn !== null &&
|
|
||||||
sn !== undefined &&
|
|
||||||
Number.isInteger(Number(sn)) &&
|
|
||||||
Number(sn) >= 0 &&
|
|
||||||
Number(sn) <= 99
|
|
||||||
)
|
|
||||||
.map((sn) => Number(sn))
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check availability (no overlap with existing loans)
|
|
||||||
const [confRows] = await conn.query(
|
|
||||||
`
|
|
||||||
SELECT COUNT(*) AS conflicts
|
|
||||||
FROM loans l
|
|
||||||
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
|
||||||
ON TRUE
|
|
||||||
WHERE jt.item_id IN (?)
|
|
||||||
AND l.deleted = 0
|
|
||||||
AND l.start_date < ?
|
|
||||||
AND COALESCE(l.returned_date, l.end_date) > ?
|
|
||||||
`,
|
|
||||||
[itemIds, end, start]
|
|
||||||
);
|
|
||||||
if (confRows?.[0]?.conflicts > 0) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "CONFLICT",
|
|
||||||
message: "One or more items are not available in the selected period",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique loan_code (retry a few times)
|
|
||||||
let loanCode = null;
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
|
|
||||||
const [exists] = await conn.query(
|
|
||||||
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
|
||||||
[candidate]
|
|
||||||
);
|
|
||||||
if (exists.length === 0) {
|
|
||||||
loanCode = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!loanCode) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
message: "Failed to generate unique loan code",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert loan (now includes lockers)
|
|
||||||
const [insertRes] = await conn.query(
|
|
||||||
`
|
|
||||||
INSERT INTO loans (username, loan_code, start_date, end_date, lockers, loaned_items_id, loaned_items_name, note)
|
|
||||||
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), CAST(? AS JSON), ?)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
username,
|
|
||||||
loanCode,
|
|
||||||
new Date(start).toISOString().slice(0, 19).replace("T", " "),
|
|
||||||
new Date(end).toISOString().slice(0, 19).replace("T", " "),
|
|
||||||
JSON.stringify(lockers),
|
|
||||||
JSON.stringify(itemIds.map((n) => Number(n))),
|
|
||||||
JSON.stringify(itemNames),
|
|
||||||
note,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
await conn.commit();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: insertRes.insertId,
|
|
||||||
loan_code: loanCode,
|
|
||||||
username,
|
|
||||||
start_date: start,
|
|
||||||
end_date: end,
|
|
||||||
items: itemIds,
|
|
||||||
item_names: itemNames,
|
|
||||||
lockers,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
console.error("createLoanInDatabase error:", err);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
message: "Failed to create loan",
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
conn.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoanInfoWithID = async (loanId) => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
|
|
||||||
loanId,
|
|
||||||
]);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows[0] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoansFromDatabase = async (username) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
|
|
||||||
[username]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, status: true, data: result };
|
|
||||||
} else if (result.length === 0) {
|
|
||||||
return { success: true, status: true, data: [] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBorrowableItemsFromDatabase = async (
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
role = 0
|
|
||||||
) => {
|
|
||||||
// Overlap if: loan.start < end AND effective_end > start
|
|
||||||
// effective_end is returned_date if set, otherwise end_date
|
|
||||||
const hasRoleFilter = Number(role) > 0;
|
|
||||||
|
|
||||||
const sql = `
|
|
||||||
SELECT i.*
|
|
||||||
FROM items i
|
|
||||||
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM loans l
|
|
||||||
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
|
||||||
WHERE jt.item_id = i.id
|
|
||||||
AND l.deleted = 0
|
|
||||||
AND l.start_date < ?
|
|
||||||
AND COALESCE(l.returned_date, l.end_date) > ?
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params = hasRoleFilter
|
|
||||||
? [role, endDate, startDate]
|
|
||||||
: [endDate, startDate];
|
|
||||||
|
|
||||||
const [rows] = await pool.query(sql, params);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SETdeleteLoanFromDatabase = async (loanId) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET deleted = 1 WHERE id = ?;",
|
|
||||||
[loanId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
} else {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getALLLoans = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM loans WHERE deleted = 0;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getItems = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const loginFunc = async (username, password) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
if (result.length > 0) return { success: true, data: result[0] };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getItems = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getALLLoans = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (username, oldPassword, newPassword) => {
|
|
||||||
// get user current password
|
|
||||||
const [user] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, oldPassword]
|
|
||||||
);
|
|
||||||
if (user.length === 0) return { success: false };
|
|
||||||
|
|
||||||
// update password
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET password = ? WHERE username = ?",
|
|
||||||
[newPassword, username]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
createLoanInDatabase,
|
|
||||||
getLoanInfoWithID,
|
|
||||||
getLoansFromDatabase,
|
|
||||||
getBorrowableItemsFromDatabase,
|
|
||||||
getALLLoans,
|
|
||||||
getItems,
|
|
||||||
SETdeleteLoanFromDatabase,
|
|
||||||
} from "./database/loansMgmt.database.js";
|
|
||||||
import { sendMailLoan } from "./services/mailer.js";
|
|
||||||
|
|
||||||
router.post("/createLoan", authenticate, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { items, startDate, endDate, note } = req.body || {};
|
|
||||||
|
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
|
||||||
return res.status(400).json({ message: "Items array is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dates are not provided, default to now .. +7 days
|
|
||||||
const start =
|
|
||||||
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
||||||
const end =
|
|
||||||
endDate ??
|
|
||||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 19)
|
|
||||||
.replace("T", " ");
|
|
||||||
|
|
||||||
// Coerce item IDs to numbers and filter invalids
|
|
||||||
const itemIds = items
|
|
||||||
.map((v) => Number(v))
|
|
||||||
.filter((n) => Number.isFinite(n));
|
|
||||||
|
|
||||||
if (itemIds.length === 0) {
|
|
||||||
return res.status(400).json({ message: "No valid item IDs provided" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createLoanInDatabase(
|
|
||||||
req.user.username,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
note,
|
|
||||||
itemIds
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
|
||||||
console.log(mailInfo);
|
|
||||||
sendMailLoan(
|
|
||||||
mailInfo.data.username,
|
|
||||||
mailInfo.data.loaned_items_name,
|
|
||||||
mailInfo.data.start_date,
|
|
||||||
mailInfo.data.end_date,
|
|
||||||
mailInfo.data.created_at
|
|
||||||
);
|
|
||||||
return res.status(201).json({
|
|
||||||
message: "Loan created successfully",
|
|
||||||
loanId: result.data.id,
|
|
||||||
loanCode: result.data.loan_code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.code === "CONFLICT") {
|
|
||||||
return res
|
|
||||||
.status(409)
|
|
||||||
.json({ message: "Items not available in the selected period" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.code === "BAD_REQUEST") {
|
|
||||||
return res.status(400).json({ message: result.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({ message: "Failed to create loan" });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("createLoan error:", err);
|
|
||||||
return res.status(500).json({ message: "Failed to create loan" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/loans", authenticate, async (req, res) => {
|
|
||||||
const result = await getLoansFromDatabase(req.user.username);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else if (result.status) {
|
|
||||||
res.status(200).json([]);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch loans" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/all-items", authenticate, async (req, res) => {
|
|
||||||
const result = await getItems();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
|
|
||||||
const loanId = req.params.id;
|
|
||||||
const result = await SETdeleteLoanFromDatabase(loanId);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ message: "Loan deleted successfully" });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to delete loan" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/all-loans", authenticate, async (req, res) => {
|
|
||||||
const result = await getALLLoans();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch loans" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/borrowable-items", authenticate, async (req, res) => {
|
|
||||||
const { startDate, endDate } = req.body || {};
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ message: "startDate and endDate are required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getBorrowableItemsFromDatabase(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
req.user.role
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
// return the array directly for consistency with /items
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
return res
|
|
||||||
.status(500)
|
|
||||||
.json({ message: "Failed to fetch borrowable items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import nodemailer from "nodemailer";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
|
||||||
if (value == null) return "N/A";
|
|
||||||
|
|
||||||
const toOut = (d) => {
|
|
||||||
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const yyyy = d.getFullYear();
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
|
||||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
||||||
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (value instanceof Date) return toOut(value);
|
|
||||||
if (typeof value === "number") return toOut(new Date(value));
|
|
||||||
|
|
||||||
const s = String(value).trim();
|
|
||||||
|
|
||||||
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
|
|
||||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
|
|
||||||
if (m) {
|
|
||||||
const [, y, M, d, h, min] = m;
|
|
||||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISO or other parseable formats
|
|
||||||
const dObj = new Date(s);
|
|
||||||
if (!isNaN(dObj.getTime())) return toOut(dObj);
|
|
||||||
|
|
||||||
return "N/A";
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
|
||||||
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
|
||||||
const itemsList =
|
|
||||||
Array.isArray(items) && items.length
|
|
||||||
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
|
|
||||||
.map(
|
|
||||||
(i) =>
|
|
||||||
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
|
|
||||||
)
|
|
||||||
.join("")}</ul>`
|
|
||||||
: "<span style='color:#111827;'>N/A</span>";
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="color-scheme" content="light">
|
|
||||||
<meta name="supported-color-schemes" content="light">
|
|
||||||
<meta name="x-apple-disable-message-reformatting">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; supported-color-schemes: light; }
|
|
||||||
body { margin:0; padding:0; }
|
|
||||||
/* Mobile stacking */
|
|
||||||
@media (max-width:480px) {
|
|
||||||
.outer { width:100% !important; }
|
|
||||||
.pad-sm { padding:16px !important; }
|
|
||||||
.w-label { width:120px !important; }
|
|
||||||
}
|
|
||||||
/* Dark-mode override safety */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
|
|
||||||
.brand-header { background:${brand} !important; color:#ffffff !important; }
|
|
||||||
a { color:${brand} !important; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
|
|
||||||
<!-- Preheader (hidden) -->
|
|
||||||
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
|
|
||||||
Neue Ausleihe erstellt – Übersicht der Buchung.
|
|
||||||
</div>
|
|
||||||
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
|
|
||||||
<tr>
|
|
||||||
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
|
|
||||||
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
|
|
||||||
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
|
|
||||||
user || "N/A"
|
|
||||||
}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
|
||||||
startDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
|
||||||
endDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
|
|
||||||
createdDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p style="margin:22px 0 0 0; font-size:14px;">
|
|
||||||
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
|
|
||||||
Übersicht öffnen
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
|
|
||||||
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
|
||||||
const itemsText =
|
|
||||||
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
|
||||||
return [
|
|
||||||
"Neue Ausleihe erstellt",
|
|
||||||
"",
|
|
||||||
`Benutzer: ${user || "N/A"}`,
|
|
||||||
`Gegenstände: ${itemsText}`,
|
|
||||||
`Start: ${formatDateTime(startDate)}`,
|
|
||||||
`Ende: ${formatDateTime(endDate)}`,
|
|
||||||
`Erstellt am: ${formatDateTime(createdDate)}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.MAIL_HOST,
|
|
||||||
port: process.env.MAIL_PORT,
|
|
||||||
secure: true,
|
|
||||||
auth: {
|
|
||||||
user: process.env.MAIL_USER,
|
|
||||||
pass: process.env.MAIL_PASSWORD,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
|
||||||
to: process.env.MAIL_SENDEES,
|
|
||||||
subject: "Eine neue Ausleihe wurde erstellt!",
|
|
||||||
text: buildLoanEmailText({
|
|
||||||
user,
|
|
||||||
items,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
createdDate,
|
|
||||||
}),
|
|
||||||
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// debugging logs
|
|
||||||
// console.log("Message sent:", info.messageId);
|
|
||||||
})();
|
|
||||||
// console.log("sendMailLoan called");
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/login", async (req, res) => {
|
|
||||||
const result = await loginFunc(req.body.username, req.body.password);
|
|
||||||
if (result.success) {
|
|
||||||
const token = await generateToken({
|
|
||||||
username: result.data.username,
|
|
||||||
is_admin: result.data.is_admin,
|
|
||||||
first_name: result.data.first_name,
|
|
||||||
last_name: result.data.last_name,
|
|
||||||
role: result.data.role,
|
|
||||||
});
|
|
||||||
res.status(200).json({ message: "Login successful", token });
|
|
||||||
} else {
|
|
||||||
res.status(401).json({ message: "Invalid credentials" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-password", authenticate, async (req, res) => {
|
|
||||||
const oldPassword = req.body.oldPassword;
|
|
||||||
const newPassword = req.body.newPassword;
|
|
||||||
const username = req.user.username;
|
|
||||||
const result = await changePassword(username, oldPassword, newPassword);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ message: "Password changed successfully" });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to change password" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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');
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use borrow_system_new;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
username varchar(100) NOT NULL UNIQUE,
|
|
||||||
password varchar(255) NOT NULL,
|
|
||||||
email varchar(255) NOT NULL,
|
|
||||||
first_name varchar(255) NOT NULL,
|
|
||||||
last_name varchar(255) NOT NULL,
|
|
||||||
role int NOT NULL,
|
|
||||||
is_admin bool NOT NULL DEFAULT false,
|
|
||||||
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE loans (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
username varchar(100) NOT NULL,
|
|
||||||
lockers json NOT NULL DEFAULT ('[]'),
|
|
||||||
loan_code Char(6) NOT NULL UNIQUE,
|
|
||||||
start_date timestamp NOT NULL,
|
|
||||||
end_date timestamp NOT NULL,
|
|
||||||
take_date timestamp NULL DEFAULT NULL,
|
|
||||||
returned_date timestamp NULL DEFAULT NULL,
|
|
||||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
loaned_items_id json NOT NULL DEFAULT ('[]'),
|
|
||||||
loaned_items_name json NOT NULL DEFAULT ('[]'),
|
|
||||||
deleted bool NOT NULL DEFAULT false,
|
|
||||||
note varchar(500) DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CHECK (loan_code REGEXP '^[0-9]{6}$')
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE items (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
item_name varchar(255) NOT NULL UNIQUE,
|
|
||||||
can_borrow_role INT NOT NULL,
|
|
||||||
in_safe bool NOT NULL DEFAULT true,
|
|
||||||
safe_nr INT DEFAULT NULL UNIQUE,
|
|
||||||
door_key INT DEFAULT NULL UNIQUE,
|
|
||||||
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
last_borrowed_person varchar(255) DEFAULT NULL,
|
|
||||||
currently_borrowing varchar(255) DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE apiKeys (
|
|
||||||
id INT NOT NULL AUTO_INCREMENT,
|
|
||||||
api_key CHAR(8) NOT NULL UNIQUE,
|
|
||||||
entry_name VARCHAR(100) NOT NULL,
|
|
||||||
last_used_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CHECK (api_key REGEXP '^[0-9]{8}$')
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
import env from "dotenv";
|
|
||||||
import info from "./info.json" assert { type: "json" };
|
|
||||||
import { authenticate } from "./services/authentication.js";
|
|
||||||
|
|
||||||
// frontend routes
|
|
||||||
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
|
|
||||||
import userMgmtRouterAPP from "./routes/app/userMgmt.route.js";
|
|
||||||
|
|
||||||
// admin routes
|
|
||||||
import userDataMgmtRouter from "./routes/admin/userDataMgmt.route.js";
|
|
||||||
import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
|
|
||||||
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
|
|
||||||
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
|
|
||||||
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
import apiRouter from "./routes/api/api.route.js";
|
|
||||||
|
|
||||||
env.config();
|
|
||||||
const app = express();
|
|
||||||
const port = 8102;
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
// Body-Parser VOR den Routen registrieren
|
|
||||||
app.use(express.json({ limit: "10mb" }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
|
||||||
|
|
||||||
// frontend routes
|
|
||||||
app.use("/api/loans", loansMgmtRouter);
|
|
||||||
app.use("/api/users", userMgmtRouterAPP);
|
|
||||||
|
|
||||||
// admin routes
|
|
||||||
app.use("/api/admin/loan-data", loanDataMgmtRouter);
|
|
||||||
app.use("/api/admin/user-data", userDataMgmtRouter);
|
|
||||||
app.use("/api/admin/item-data", itemDataMgmtRouter);
|
|
||||||
app.use("/api/admin/api-data", apiDataMgmtRouter);
|
|
||||||
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.use("/api", apiRouter);
|
|
||||||
|
|
||||||
app.set("view engine", "ejs");
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server is running on port: ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/verify", authenticate, async (req, res) => {
|
|
||||||
res.status(200).json({ message: "Token is valid", user: req.user });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
res.send(info);
|
|
||||||
});
|
|
||||||
|
|
||||||
// error handling code
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error(err.stack);
|
|
||||||
res.status(500).send("Something broke!");
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
|
||||||
import env from "dotenv";
|
|
||||||
import { verifyAPIKeyDB } from "./database.js";
|
|
||||||
env.config();
|
|
||||||
|
|
||||||
const secretKey = process.env.SECRET_KEY;
|
|
||||||
if (!secretKey) {
|
|
||||||
throw new Error("Missing SECRET_KEY environment variable");
|
|
||||||
}
|
|
||||||
const secret = new TextEncoder().encode(secretKey);
|
|
||||||
|
|
||||||
export async function generateToken(payload) {
|
|
||||||
return await new SignJWT(payload)
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime("2h")
|
|
||||||
.sign(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authenticateAdmin(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
if (!authHeader) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [scheme, token] = authHeader.split(" ");
|
|
||||||
if (!/^Bearer$/i.test(scheme) || !token) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
if (!payload?.admin) {
|
|
||||||
return res.status(403).json({ message: "Forbidden: admin only" });
|
|
||||||
}
|
|
||||||
req.user = payload;
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "Forbidden 403" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authenticate(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
const apiKey = req.params.key;
|
|
||||||
|
|
||||||
if (authHeader) {
|
|
||||||
const parts = authHeader.split(" ");
|
|
||||||
const scheme = parts[0];
|
|
||||||
const token = parts[1];
|
|
||||||
|
|
||||||
if (!/^Bearer$/i.test(scheme) || !token) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
req.user = payload;
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
|
|
||||||
}
|
|
||||||
} else if (apiKey) {
|
|
||||||
try {
|
|
||||||
await verifyAPIKey(apiKey);
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "API Key invalid" }); // fix: don't chain after sendStatus
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" }); // no credentials
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyAPIKey(apiKey) {
|
|
||||||
const result = await verifyAPIKeyDB(apiKey);
|
|
||||||
|
|
||||||
if (result.valid) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid API Key");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyToken(token) {
|
|
||||||
const { payload } = await jwtVerify(token, secret, {
|
|
||||||
algorithms: ["HS256"],
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const verifyAPIKeyDB = async (apiKey) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM apiKeys WHERE api_key = ?;",
|
|
||||||
[apiKey]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
const [lastUsed] = await pool.query(
|
|
||||||
"UPDATE apiKeys SET last_used_at = NOW() WHERE api_key = ?;",
|
|
||||||
[apiKey]
|
|
||||||
);
|
|
||||||
if (lastUsed.affectedRows > 0) {
|
|
||||||
return { valid: true };
|
|
||||||
} else {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,55 +1,75 @@
|
|||||||
services:
|
services:
|
||||||
usr-frontend_v2:
|
borrow_system-frontend:
|
||||||
container_name: borrow_system-usr-frontend
|
container_name: borrow_system-frontend
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "8101:8101"
|
||||||
networks:
|
networks:
|
||||||
- proxynet
|
- proxynet
|
||||||
build: ./FrontendV2
|
- borrow_system-internal
|
||||||
ports:
|
environment:
|
||||||
- "8101:80"
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
admin-frontend:
|
admin-frontend:
|
||||||
container_name: borrow_system-admin-frontend
|
container_name: admin-frontend
|
||||||
networks:
|
|
||||||
- proxynet
|
|
||||||
build: ./admin
|
build: ./admin
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
- borrow_system-internal
|
||||||
ports:
|
ports:
|
||||||
- "8103:80"
|
- "8103:8103"
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
backend_v2:
|
|
||||||
container_name: borrow_system-backend_v2
|
|
||||||
networks:
|
|
||||||
- proxynet
|
|
||||||
build: ./backendV2
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
- CHOKIDAR_USEPOLLING=true
|
||||||
DB_HOST: mysql_v2
|
volumes:
|
||||||
DB_USER: root
|
- ./admin:/app
|
||||||
DB_PASSWORD: ${DB_PASSWORD_V2}
|
- /app/node_modules
|
||||||
DB_NAME: borrow_system_new
|
|
||||||
depends_on:
|
|
||||||
- mysql_v2
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mysql_v2:
|
borrow_system-backend:
|
||||||
container_name: borrow_system-mysql-v2
|
container_name: borrow_system-backend
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8102:8102"
|
||||||
networks:
|
networks:
|
||||||
- proxynet
|
- proxynet
|
||||||
|
- borrow_system-internal
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: borrow_system
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
volumes:
|
||||||
|
- ./backend:/borrow_system-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: borrow_system-mysql
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
|
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||||
MYSQL_DATABASE: borrow_system_new
|
MYSQL_DATABASE: borrow_system
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-v2-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
||||||
|
ports:
|
||||||
|
- "3309:3306"
|
||||||
|
networks:
|
||||||
|
- borrow_system-internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
mysql-v2-data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxynet:
|
proxynet:
|
||||||
external: true
|
external: true
|
||||||
|
borrow_system-internal:
|
||||||
|
external: false
|
||||||
|
|||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8101
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -4,19 +4,15 @@ This template provides a minimal setup to get React working in Vite with HMR and
|
|||||||
|
|
||||||
Currently, two official plugins are available:
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default defineConfig([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -24,11 +20,11 @@ export default defineConfig([
|
|||||||
// Other configs...
|
// Other configs...
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
// Alternatively, use this for stricter rules
|
// Alternatively, use this for stricter rules
|
||||||
tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
// Optionally, add this for stylistic rules
|
// Optionally, add this for stylistic rules
|
||||||
tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
// Other configs...
|
// Other configs...
|
||||||
],
|
],
|
||||||
@@ -50,7 +46,7 @@ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-re
|
|||||||
import reactX from 'eslint-plugin-react-x'
|
import reactX from 'eslint-plugin-react-x'
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
export default defineConfig([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -3,9 +3,9 @@ import globals from 'globals'
|
|||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/shapes.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontendv2</title>
|
<title>Ausleihsystem</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
2862
FrontendV2/package-lock.json → frontend/package-lock.json
generated
2862
FrontendV2/package-lock.json → frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,21 +10,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^3.28.0",
|
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"i18next": "^25.6.0",
|
|
||||||
"jotai": "^2.15.0",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.6",
|
"primereact": "^10.9.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-i18next": "^16.2.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-router-dom": "^7.8.0",
|
"react-router-dom": "^7.8.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"split-lines": "^3.0.0",
|
"split-lines": "^3.0.0",
|
||||||
@@ -46,7 +39,6 @@
|
|||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.0",
|
"typescript-eslint": "^8.39.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0"
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 420 B After Width: | Height: | Size: 420 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user