Compare commits
201 Commits
dev_v1
...
98834a9270
| Author | SHA1 | Date | |
|---|---|---|---|
| 98834a9270 | |||
| c8a230979f | |||
| 581cd4a1fd | |||
| c53e6e095a | |||
| eaa325668c | |||
| 8d04465705 | |||
| 1fa8b4a9a7 | |||
| 3ba3c1c0cb | |||
| ee54d51f8b | |||
| a8dab549af | |||
| 06976f7972 | |||
| 8589971dc8 | |||
| 6ec8e19737 | |||
| 977a6c1b16 | |||
| 38c647c62f | |||
| 757b316b49 | |||
| d05e9ab3ee | |||
| 9f44a4796d | |||
| 3f59ed6951 | |||
| dc0a68f7f1 | |||
| fe3a06e5ce | |||
| 6efb0fee80 | |||
| 2e98fa50de | |||
| 179f5686d1 | |||
| 7221ee1843 | |||
| 28373e0231 | |||
| ae0cb5af81 | |||
| 80f38fcd3d | |||
| 70f3d1fdcc | |||
| 4b08a574d8 | |||
| 5aa8a32020 | |||
| b58a04b030 | |||
| e1615f9345 | |||
| ce760eb721 | |||
| 109cd7660a | |||
| 727bd832dc | |||
| 3b93b1fa23 | |||
| 9963731b10 | |||
| 5546401aa4 | |||
| 2f405539fb | |||
| c803e42a76 | |||
| 76c0e6a64b | |||
| ebda6424c7 | |||
| e362515eff | |||
| 31960d1ff8 | |||
| 3bf5560834 | |||
| 4c60fea4c4 | |||
| 0577a63205 | |||
| fd2ccaa747 | |||
| df6b5eac59 | |||
| d64489aed4 | |||
| cb6b5858e5 | |||
| 85e6d7fe00 | |||
| 4b9f55268c | |||
| 90ca266793 | |||
| b9d67cd147 | |||
| 58b5d29040 | |||
| baa74adcc1 | |||
| 07d194ee6a | |||
| 0a4d981808 | |||
| a78118da8d | |||
| 8ce882a745 | |||
| c6571033b0 | |||
| 238cd9254a | |||
| ca8030afbd | |||
| 1076b12668 | |||
| 80cb393768 | |||
| 79486fe1cb | |||
| 09ea1cb301 | |||
| db21bcf1b4 | |||
| 4ec14416ca | |||
| 6556d2c01d | |||
| 903e360c29 | |||
| c5a9a09ef3 | |||
| a191c9c053 | |||
| 084a0fa2e2 | |||
| 88a2c74e88 | |||
| 3a03457f5a | |||
| 757e13efe4 | |||
| d2ee9d73c7 | |||
| 8c10e6e63f | |||
| 24bf5fcaaf | |||
| 6f03fd8032 | |||
| 17010d5480 | |||
| a8c5ef25f7 | |||
| eccd0135fc | |||
| 8f294278d4 | |||
| 16e48aaf3f | |||
| e49700071b | |||
| a8b4ac3d60 | |||
| 974a5a75d8 | |||
| b9783a1909 | |||
| 304e73b459 | |||
| 12277abb9e | |||
| 20d22d6ce4 | |||
| 27d21efefa | |||
| 3e67bf9052 | |||
| 3438321765 | |||
| 29d47ddd9b | |||
| 7b298180e0 | |||
| 9b3bd76c42 | |||
| 5b73b44e79 | |||
| cf4a003c51 | |||
| 592b60082b | |||
| a34292bda1 | |||
| 2f37ae8067 | |||
| f49da68e15 | |||
| 9491da2950 | |||
| a75ba12897 | |||
| b52fe07618 | |||
| 0d3de4f705 | |||
| ef3f953ebd | |||
| 1db4e69322 | |||
| 83f1c9d191 | |||
| af513034ef | |||
| 3358c8f669 | |||
| d3f7a7570f | |||
| c502601a2f | |||
| 070a390da8 | |||
| bcf93ee9eb | |||
| 9daff3ea5c | |||
| 71fea52da7 | |||
| a8821ceca8 | |||
| 7e668e17d3 | |||
| 965a4b97ee | |||
| 6054173b03 | |||
| 5ba35bb471 | |||
| 47b5590394 | |||
| 47fec60b5b | |||
| e9319b49ec | |||
| b98e38b38b | |||
| a24a3033d3 | |||
| d94d68aa33 | |||
| cc0dcaf664 | |||
| 7a79bf4436 | |||
| 4b00dd6554 | |||
| ba34a97328 | |||
| a013ad0bb8 | |||
| d7240584f9 | |||
| a0bdf5539c | |||
| 770025f8fc | |||
| 960e91c38a | |||
| b99f52f09a | |||
| 86af1a5edf | |||
| 49d4d13afc | |||
| 45fa095eaf | |||
| 23be7e12c7 | |||
| 6ea1ff799c | |||
| 5131266242 | |||
| bb17bc735c | |||
| af7d15c97a | |||
| 04453fd885 | |||
| bf36a6605f | |||
| 8f9696991f | |||
| 9cad1e8b6b | |||
| 880029a0cf | |||
| 32abe60d98 | |||
| ea965971f1 | |||
| eff1f61422 | |||
| a4c0323100 | |||
| fc755edadf | |||
| 0fca896cc2 | |||
| f83f321876 | |||
| b9d637665c | |||
| 378720b235 | |||
| 49f4ba8483 | |||
| ea5d31384a | |||
| 7f5f464841 | |||
| ab93c9959d | |||
| 679ef7dcbd | |||
| c3572a3d70 | |||
| 4fc60a08d9 | |||
| 5159877d8d | |||
| b3ddfd9aa5 | |||
| 755ebfd06b | |||
| e198fce791 | |||
| 8341404f45 | |||
| 5291752403 | |||
| c0ae12185a | |||
| 68f13f369c | |||
| a24b2697b0 | |||
| 13a654561e | |||
| 5a058de2f0 | |||
| b8f13a37fd | |||
| 423075e746 | |||
| 99810d2b7d | |||
| a1972f26d3 | |||
| 866f860d5a | |||
| 16e1dca43c | |||
| c47c311ecd | |||
| bd504f7817 | |||
| b0914314bb | |||
| 48c16350b7 | |||
| b217769961 | |||
| 769d1117eb | |||
| c77bef5cf3 | |||
| 217803ba8f | |||
| 8fb70ccccd | |||
| 311de4f78b | |||
| 7b36514e27 | |||
| 7be418ad75 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -109,8 +109,11 @@ backend/public/uploads/
|
||||
*.sqlite3
|
||||
|
||||
# API keys and secrets (additional protection)
|
||||
config/
|
||||
secrets/
|
||||
keys/
|
||||
|
||||
ToDo.txt
|
||||
|
||||
|
||||
# only in development branch
|
||||
next-env.d.ts
|
||||
@@ -3,3 +3,5 @@
|
||||
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.
|
||||
|
||||
If you need help, see HELP.md file in this directory.
|
||||
@@ -1,306 +1,177 @@
|
||||
# Backend API docs
|
||||
# Borrow System API Documentation
|
||||
|
||||
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
|
||||
## Overview
|
||||
|
||||
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` 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`.**
|
||||
|
||||
This is the file that you can use to build an API.
|
||||
|
||||
But first you have to get the Admin API key, stored in an `.env` file on my server.
|
||||
|
||||
---
|
||||
The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
|
||||
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
|
||||
|
||||
Example: `/apiV2/items/{ADMIN_ID}`
|
||||
## Endpoints
|
||||
|
||||
---
|
||||
The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
|
||||
|
||||
## URL
|
||||
### Get All Items
|
||||
|
||||
- The frontend is currently running on `https://insta.the1s.de`.
|
||||
`GET /items/:key`
|
||||
|
||||
- The backend is currently running on `https://backend.insta.the1s.de`.
|
||||
Returns all items in the system.
|
||||
|
||||
You can see the status of this and all my other services at `https://status.the1s.de`.
|
||||
**Response 200:**
|
||||
|
||||
---
|
||||
|
||||
## Current endpoints
|
||||
|
||||
### 1. Get All Items
|
||||
|
||||
**GET** `/apiV2/items/:key`
|
||||
|
||||
Returns a list of all items and their details.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```
|
||||
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"item_name": "DJI 1er Mikro",
|
||||
"can_borrow_role": 4,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"item_name": "DJI 2er Mikro 1",
|
||||
"can_borrow_role": 4,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"item_name": "DJI 2er Mikro 2",
|
||||
"can_borrow_role": 4,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"item_name": "Rode Richt Mikrofon",
|
||||
"can_borrow_role": 2,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"item_name": "Kamera Stativ",
|
||||
"item_name": "Laptop",
|
||||
"can_borrow_role": 1,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"item_name": "SONY Kamera - inkl. Akkus und Objektiv",
|
||||
"can_borrow_role": 1,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"item_name": "MacBook inkl. Adapter",
|
||||
"can_borrow_role": 2,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"item_name": "SD Karten",
|
||||
"can_borrow_role": 3,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"item_name": "Kameragimbal",
|
||||
"can_borrow_role": 1,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"item_name": "ATEM MINI PRO",
|
||||
"can_borrow_role": 1,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"item_name": "Handygimbal",
|
||||
"can_borrow_role": 4,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"item_name": "Kameralfter",
|
||||
"can_borrow_role": 1,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"item_name": "Kleine Kamera 1 - inkl. Objektiv",
|
||||
"can_borrow_role": 2,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"item_name": "Kleine Kamera 2 - inkl. Objektiv",
|
||||
"can_borrow_role": 2,
|
||||
"inSafe": 1,
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||
"in_safe": true,
|
||||
"safe_nr": 3,
|
||||
"door_key": 101,
|
||||
"last_borrowed_person": "jdoe",
|
||||
"currently_borrowing": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each item has the following properties:
|
||||
**Response 500:**
|
||||
|
||||
- `id`: The unique identifier for the item.
|
||||
- `item_name`: The name of the item.
|
||||
- `can_borrow_role`: The role ID that is allowed to borrow the item.
|
||||
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time.
|
||||
|
||||
_You also get an http 200 status code._
|
||||
```json
|
||||
{ "message": "Failed to fetch items" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Change Item Safe State
|
||||
### Change Item Safe State
|
||||
|
||||
**POST** `/apiV2/controlInSafe/:key/:itemId/:state`
|
||||
`POST /change-state/:key/:itemId`
|
||||
|
||||
Updates the `inSafe` state of an item (whether it is in the locker).
|
||||
Toggles the `in_safe` boolean state of an item.
|
||||
|
||||
- `state` must be `"1"` (in safe) or `"0"` (not in safe).
|
||||
**URL Parameters:**
|
||||
|
||||
#### Example Request
|
||||
- **key** - API key
|
||||
- **itemId** - The item's ID
|
||||
|
||||
**Response 200:** Returns on successful toggle.
|
||||
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{ "message": "Failed to update item state" }
|
||||
```
|
||||
POST https://backend.insta.the1s.de/apiV2/controlInSafe/your_admin_key/item_id/new_item_state
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```
|
||||
{}
|
||||
```
|
||||
|
||||
_An empty object means, that the operation was successful and no further information is returned._
|
||||
|
||||
_You also get an http 200 status code._
|
||||
|
||||
---
|
||||
|
||||
### 3. Set Return Date
|
||||
### Get Loan by Code
|
||||
|
||||
**POST** `/apiV2/setReturnDate/:key/:loan_code`
|
||||
`GET /get-loan-by-code/:key/:loan_code`
|
||||
|
||||
Sets the `returned_date` of a loan to the current server time.
|
||||
Retrieves loan details by its 6-digit loan code.
|
||||
|
||||
- `loan_code`: The unique code of the loan.
|
||||
**URL Parameters:**
|
||||
|
||||
#### Example Request
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
```
|
||||
POST https://backend.insta.the1s.de/apiV2/setReturnDate/your_admin_key/your_loan_code
|
||||
```
|
||||
**Response 200:**
|
||||
|
||||
#### Example Response
|
||||
|
||||
```
|
||||
{}
|
||||
```
|
||||
|
||||
_An empty object means, that the operation was successful and no further information is returned._
|
||||
|
||||
_You also get an http 200 status code._
|
||||
|
||||
---
|
||||
|
||||
### 4. Set Take Date
|
||||
|
||||
**POST** `/apiV2/setTakeDate/:key/:loan_code`
|
||||
|
||||
Sets the `take_date` of a loan to the current server time.
|
||||
|
||||
- `loan_code`: The unique code of the loan.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```
|
||||
POST https://backend.insta.the1s.de/apiV2/setTakeDate/your_admin_key/your_loan_code
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```
|
||||
{}
|
||||
```
|
||||
|
||||
_An empty object means, that the operation was successful and no further information is returned._
|
||||
|
||||
_You also get an http 2xx status code._
|
||||
|
||||
---
|
||||
|
||||
### 5. Get whole loan by loan code
|
||||
|
||||
**POST** `/getLoanByCode/:key/:loan_code`
|
||||
|
||||
Retrieves the details of a specific loan by its unique code.
|
||||
|
||||
- `loan_code`: The unique code of the loan.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```
|
||||
GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"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,
|
||||
"username": "jdoe",
|
||||
"returned_date": null,
|
||||
"created_at": "2025-08-20T11:23:40.000Z",
|
||||
"loaned_items_id": [
|
||||
8,
|
||||
9
|
||||
],
|
||||
"loaned_items_name": [
|
||||
"SD Karten",
|
||||
"Kameragimbal"
|
||||
]
|
||||
"take_date": "2024-01-15T10:30:00.000Z",
|
||||
"lockers": [1, 3]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_You also get an http 200 status code._
|
||||
**Response 404:**
|
||||
|
||||
If the loan id does not exist, you will receive a 404 status code and an error message.
|
||||
|
||||
```
|
||||
{
|
||||
"message": "Loan not found"
|
||||
}
|
||||
```json
|
||||
{ "message": "Loan not found" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Set Take Date
|
||||
|
||||
`POST /set-take-date/:key/:loan_code`
|
||||
|
||||
Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
**Response 200:** Empty JSON object on success.
|
||||
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{ "message": "Loan not found or already taken" }
|
||||
```
|
||||
|
||||
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
|
||||
|
||||
---
|
||||
|
||||
### Set Return Date
|
||||
|
||||
`POST /set-return-date/:key/:loan_code`
|
||||
|
||||
Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
**Response 200:** Empty JSON object on success.
|
||||
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{ "message": "Failed to set return date" }
|
||||
```
|
||||
|
||||
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
|
||||
|
||||
---
|
||||
|
||||
### Open Door
|
||||
|
||||
`GET /open-door/:key/:doorKey`
|
||||
|
||||
Toggles the safe state of an item identified by its door key and returns the associated safe number.
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- **key** - API key
|
||||
- **doorKey** - The door key identifier assigned to an item
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"safe_nr": 3,
|
||||
"id": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{ "message": "Failed to open door" }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- `403 Forbidden`: Invalid or missing API key.
|
||||
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
|
||||
- `500 Internal Server Error`: Database or server error.
|
||||
|
||||
---
|
||||
|
||||
If you have questions or want to collaborate, please reach out to me!
|
||||
All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
|
||||
|
||||
19
FrontendV2/Dockerfile
Normal file
19
FrontendV2/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
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;"]
|
||||
73
FrontendV2/README.md
Normal file
73
FrontendV2/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
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-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
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
FrontendV2/eslint.config.js
Normal file
23
FrontendV2/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,8 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/shapes.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ausleihsystem</title>
|
||||
</head>
|
||||
26
FrontendV2/nginx.conf
Normal file
26
FrontendV2/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
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:8004/;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
6132
FrontendV2/package-lock.json
generated
Normal file
6132
FrontendV2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
FrontendV2/package.json
Normal file
52
FrontendV2/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.28.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"i18next": "^25.6.0",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.6",
|
||||
"react": "^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-toastify": "^11.0.5",
|
||||
"split-lines": "^3.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"vite": "^7.1.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 420 B After Width: | Height: | Size: 420 B |
73
FrontendV2/src/App.css
Normal file
73
FrontendV2/src/App.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@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);
|
||||
}
|
||||
98
FrontendV2/src/App.tsx
Normal file
98
FrontendV2/src/App.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 { Flex } from "@chakra-ui/react";
|
||||
import { Footer } from "./components/footer/Footer";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import { ContactPage } from "./pages/ContactPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
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="100dvh">
|
||||
<Flex as="main" flex="1" direction="column">
|
||||
<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 path="/contact" element={<ContactPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</UserContext.Provider>
|
||||
</Flex>
|
||||
<Footer />
|
||||
</Flex>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
322
FrontendV2/src/components/Header.tsx
Normal file
322
FrontendV2/src/components/Header.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
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,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
CalendarPlus,
|
||||
MoreVertical,
|
||||
Languages,
|
||||
Table,
|
||||
ContactRound,
|
||||
} 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/?action=_pages",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<LifeBuoy size={16} />
|
||||
<Text as="span">{t("help")}</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="contact"
|
||||
onSelect={() => navigate("/contact", { replace: true })}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<ContactRound size={16} />
|
||||
<Text as="span">{t("contact")}</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/?action=_pages"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<HStack gap={2}>
|
||||
<LifeBuoy size={18} />
|
||||
<Text as="span">{t("help")}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => navigate("/contact", { replace: true })}
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<ContactRound size={18} />
|
||||
<Text as="span">{t("contact")}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
220
FrontendV2/src/components/UserDialogue.tsx
Normal file
220
FrontendV2/src/components/UserDialogue.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
22
FrontendV2/src/components/footer/Footer.tsx
Normal file
22
FrontendV2/src/components/footer/Footer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
fontSize="sm"
|
||||
>
|
||||
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>
|
||||
);
|
||||
};
|
||||
29
FrontendV2/src/components/footer/versionInfo.query.ts
Normal file
29
FrontendV2/src/components/footer/versionInfo.query.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
22
FrontendV2/src/components/myChakra/MyAlert.tsx
Normal file
22
FrontendV2/src/components/myChakra/MyAlert.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
108
FrontendV2/src/components/ui/color-mode.tsx
Normal file
108
FrontendV2/src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
159
FrontendV2/src/components/ui/password-input.tsx
Normal file
159
FrontendV2/src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ButtonProps,
|
||||
GroupProps,
|
||||
InputProps,
|
||||
StackProps,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
Stack,
|
||||
mergeRefs,
|
||||
useControllableState,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu"
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
/**
|
||||
* The default visibility state of the password input.
|
||||
*/
|
||||
defaultVisible?: boolean
|
||||
/**
|
||||
* The controlled visibility state of the password input.
|
||||
*/
|
||||
visible?: boolean
|
||||
/**
|
||||
* Callback invoked when the visibility state changes.
|
||||
*/
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
/**
|
||||
* Custom icons for the visibility toggle button.
|
||||
*/
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends InputProps,
|
||||
PasswordVisibilityProps {
|
||||
rootProps?: GroupProps
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
PasswordInputProps
|
||||
>(function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
})
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setVisible(!visible)
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs(ref, inputRef)}
|
||||
type={visible ? "text" : "password"}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
})
|
||||
|
||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me="-2"
|
||||
aspectRatio="square"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
height="calc(100% - {spacing.2})"
|
||||
aria-label="Toggle password visibility"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PasswordStrengthMeterProps
|
||||
>(function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props
|
||||
|
||||
const percent = (value / max) * 100
|
||||
const { label, colorPalette } = getColorPalette(percent)
|
||||
|
||||
return (
|
||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
||||
<HStack width="full" {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height="1"
|
||||
flex="1"
|
||||
rounded="sm"
|
||||
data-selected={index < value ? "" : undefined}
|
||||
layerStyle="fill.subtle"
|
||||
colorPalette="gray"
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: "fill.solid",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
||||
</Stack>
|
||||
)
|
||||
})
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: "Low", colorPalette: "red" }
|
||||
case percent < 66:
|
||||
return { label: "Medium", colorPalette: "orange" }
|
||||
default:
|
||||
return { label: "High", colorPalette: "green" }
|
||||
}
|
||||
}
|
||||
15
FrontendV2/src/components/ui/provider.tsx
Normal file
15
FrontendV2/src/components/ui/provider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
|
||||
import {
|
||||
ColorModeProvider,
|
||||
type ColorModeProviderProps,
|
||||
} from "./color-mode"
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
)
|
||||
}
|
||||
43
FrontendV2/src/components/ui/toaster.tsx
Normal file
43
FrontendV2/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
46
FrontendV2/src/components/ui/tooltip.tsx
Normal file
46
FrontendV2/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
)
|
||||
},
|
||||
)
|
||||
4
FrontendV2/src/config/api.config.ts
Normal file
4
FrontendV2/src/config/api.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
70
FrontendV2/src/index.css
Normal file
70
FrontendV2/src/index.css
Normal file
@@ -0,0 +1,70 @@
|
||||
@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);
|
||||
}
|
||||
18
FrontendV2/src/main.tsx
Normal file
18
FrontendV2/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
84
FrontendV2/src/pages/ContactPage.tsx
Normal file
84
FrontendV2/src/pages/ContactPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Field,
|
||||
Textarea,
|
||||
Button,
|
||||
Alert,
|
||||
Container,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import Cookies from "js-cookie";
|
||||
import { Header } from "@/components/Header";
|
||||
|
||||
interface Alert {
|
||||
type: "info" | "warning" | "success" | "error" | "neutral";
|
||||
headline: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ContactPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState("");
|
||||
const [alert, setAlert] = useState<Alert | null>(null);
|
||||
|
||||
const sendMessage = async () => {
|
||||
// Logic to send the message
|
||||
const result = await fetch(`${API_BASE}/api/users/contact`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setAlert({
|
||||
type: "success",
|
||||
headline: t("contactPage_successHeadline"),
|
||||
text: t("contactPage_successText"),
|
||||
});
|
||||
setMessage("");
|
||||
} else {
|
||||
setAlert({
|
||||
type: "error",
|
||||
headline: t("contactPage_errorHeadline"),
|
||||
text: t("contactPage_errorText"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
<Field.Root invalid={message === ""}>
|
||||
<Field.Label>
|
||||
<Text>{t("contactPage_messageDescription")}</Text>
|
||||
<Field.RequiredIndicator />
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
placeholder={t("contactPage_messagePlaceholder")}
|
||||
variant="subtle"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
{message === "" && (
|
||||
<Field.ErrorText>{t("contactPage_messageErrorText")}</Field.ErrorText>
|
||||
)}
|
||||
</Field.Root>
|
||||
{alert && (
|
||||
<Alert.Root status={alert.type}>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{alert.headline}</Alert.Title>
|
||||
<Alert.Description>{alert.text}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
)}
|
||||
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
200
FrontendV2/src/pages/HomePage.tsx
Normal file
200
FrontendV2/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
266
FrontendV2/src/pages/Landingpage.tsx
Normal file
266
FrontendV2/src/pages/Landingpage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Table,
|
||||
Heading,
|
||||
HStack,
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Container,
|
||||
} from "@chakra-ui/react";
|
||||
import MyAlert from "@/components/myChakra/MyAlert";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import Cookies from "js-cookie";
|
||||
import { Header } from "@/components/Header";
|
||||
|
||||
export const formatDateTime = (value: string | null | undefined) => {
|
||||
if (!value) return "N/A";
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!m) return "N/A";
|
||||
const [, y, M, d, h, min] = m;
|
||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||
};
|
||||
|
||||
type Loan = {
|
||||
id: number;
|
||||
username: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
returned_date: string | null;
|
||||
take_date: string | null;
|
||||
loaned_items_name: string[] | string;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
type Device = {
|
||||
id: number;
|
||||
item_name: string;
|
||||
can_borrow_role: string;
|
||||
in_safe: number;
|
||||
entry_created_at: string;
|
||||
last_borrowed_person: string | null;
|
||||
currently_borrowing: string | null;
|
||||
};
|
||||
|
||||
const Landingpage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loans, setLoans] = useState<Loan[]>([]);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string,
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const loanRes = await fetch(`${API_BASE}/api/loans/all-loans`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
const loanData = await loanRes.json();
|
||||
if (Array.isArray(loanData)) {
|
||||
setLoans(loanData);
|
||||
} else {
|
||||
setError(
|
||||
"error",
|
||||
t("error-by-loading"),
|
||||
t("unexpected-date-format_loan"),
|
||||
);
|
||||
}
|
||||
|
||||
const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
const deviceData = await deviceRes.json();
|
||||
if (Array.isArray(deviceData)) {
|
||||
setDevices(deviceData);
|
||||
} else {
|
||||
setError(
|
||||
"error",
|
||||
t("error-by-loading"),
|
||||
t("unexpected-date-format_device"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError("error", t("error-by-loading"), t("error-fetching-loans"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
|
||||
<Heading as="h2" size="md" mb={4}>
|
||||
{t("all-loans")}
|
||||
</Heading>
|
||||
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<Table.Root size="sm" striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("username")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("start-date")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("end-date")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("rented-items")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("take-date")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("return-date")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("note")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{loans.map((loan) => (
|
||||
<Table.Row key={loan.id}>
|
||||
<Table.Cell>{loan.id}</Table.Cell>
|
||||
<Table.Cell>{loan.username}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.start_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.end_date)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{Array.isArray(loan.loaned_items_name)
|
||||
? loan.loaned_items_name.join(", ")
|
||||
: loan.loaned_items_name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
|
||||
<Table.Cell>{loan.note}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
|
||||
{!isLoading && loans.length === 0 && !isError && (
|
||||
<Text color="gray.500" mt={2}>
|
||||
{t("no-loans-found")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Heading as="h2" size="md" mb={4}>
|
||||
{t("all-devices")}
|
||||
</Heading>
|
||||
|
||||
{/* Responsive Grid mit gleich hohen Karten */}
|
||||
<SimpleGrid minChildWidth="200px" gap={2} alignItems="stretch">
|
||||
{devices.map((device) => (
|
||||
<Card.Root
|
||||
key={device.id}
|
||||
size="sm"
|
||||
bg={device.in_safe ? "green" : "red"}
|
||||
h="full"
|
||||
minH="100px"
|
||||
>
|
||||
<Card.Header>
|
||||
<Heading size="md">
|
||||
<strong>{device.item_name}</strong>
|
||||
</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<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.Root>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
|
||||
<Text fontWeight="medium" color="fg.muted">
|
||||
{t("legend")}:
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
colorPalette="green"
|
||||
pointerEvents="none"
|
||||
cursor="default"
|
||||
borderRadius="full"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Text>{t("in-locker")}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
colorPalette="red"
|
||||
pointerEvents="none"
|
||||
cursor="default"
|
||||
borderRadius="full"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Text>{t("not-in-locker")}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</HStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Landingpage;
|
||||
119
FrontendV2/src/pages/LoginPage.tsx
Normal file
119
FrontendV2/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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, useLocation } from "react-router-dom";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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();
|
||||
const location = useLocation();
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate(from, { replace: true });
|
||||
window.location.reload(); // if deleted, the user context is not updated in time
|
||||
}
|
||||
}, [isLoggedIn, navigate, from]);
|
||||
|
||||
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(from, { replace: true });
|
||||
};
|
||||
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
363
FrontendV2/src/pages/MyLoansPage.tsx
Normal file
363
FrontendV2/src/pages/MyLoansPage.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
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}`;
|
||||
};
|
||||
|
||||
const handleTakeAction = async (loanCode: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/loans/set-take-date/${loanCode}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("error-take-loan"));
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the loan in state
|
||||
setLoans((prev) =>
|
||||
prev.map((loan) =>
|
||||
loan.loan_code === loanCode
|
||||
? { ...loan, take_date: new Date().toISOString() }
|
||||
: loan,
|
||||
),
|
||||
);
|
||||
setMsgStatus("success");
|
||||
setMsgTitle(t("success"));
|
||||
setMsgDescription(t("take-loan-success"));
|
||||
setIsMsg(true);
|
||||
} catch (e) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("network-error"));
|
||||
setIsMsg(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReturnAction = async (loanCode: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/loans/set-return-date/${loanCode}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("error-return-loan"));
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the loan in state
|
||||
setLoans((prev) =>
|
||||
prev.map((loan) =>
|
||||
loan.loan_code === loanCode
|
||||
? { ...loan, returned_date: new Date().toISOString() }
|
||||
: loan,
|
||||
),
|
||||
);
|
||||
setMsgStatus("success");
|
||||
setMsgTitle(t("success"));
|
||||
setMsgDescription(t("return-loan-success"));
|
||||
setIsMsg(true);
|
||||
} catch (e) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("network-error"));
|
||||
setIsMsg(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<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>
|
||||
{loan.take_date ? (
|
||||
formatDate(loan.take_date)
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
colorPalette="teal"
|
||||
onClick={() => handleTakeAction(loan.loan_code)}
|
||||
>
|
||||
{t("take")}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{loan.returned_date ? (
|
||||
formatDate(loan.returned_date)
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
colorPalette="blue"
|
||||
onClick={() => handleReturnAction(loan.loan_code)}
|
||||
disabled={!loan.take_date}
|
||||
>
|
||||
{t("return")}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
FrontendV2/src/states/Atoms.tsx
Normal file
16
FrontendV2/src/states/Atoms.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
22
FrontendV2/src/states/Context.ts
Normal file
22
FrontendV2/src/states/Context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
36
FrontendV2/src/states/README.md
Normal file
36
FrontendV2/src/states/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
79
FrontendV2/src/utils/Fetcher.ts
Normal file
79
FrontendV2/src/utils/Fetcher.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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:
|
||||
"An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
20
FrontendV2/src/utils/ProtectedRoutes.tsx
Normal file
20
FrontendV2/src/utils/ProtectedRoutes.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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 }} />
|
||||
);
|
||||
};
|
||||
34
FrontendV2/src/utils/i18n/index.ts
Normal file
34
FrontendV2/src/utils/i18n/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
92
FrontendV2/src/utils/i18n/locales/de/de.json
Normal file
92
FrontendV2/src/utils/i18n/locales/de/de.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"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",
|
||||
"contactPage_successHeadline": "Nachricht erfolgreich gesendet",
|
||||
"contactPage_successText": "Vielen Dank, dass Sie uns kontaktiert haben. Wir werden uns so schnell wie möglich bei Ihnen melden.",
|
||||
"contactPage_errorHeadline": "Fehler beim Senden der Nachricht",
|
||||
"contactPage_errorText": "Beim Senden Ihrer Nachricht ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
|
||||
"contactPage_sendButton": "Nachricht senden",
|
||||
"contactPage_messageLabel": "Nachricht",
|
||||
"contactPage_messagePlaceholder": "Geben Sie hier Ihre Nachricht ein...",
|
||||
"contactPage_messageErrorText": "Dieses Feld darf nicht leer sein.",
|
||||
"contact": "Kontakt",
|
||||
"take": "Abholen",
|
||||
"return": "Zurückgeben",
|
||||
"serverError": "Serverfehler. Bitte versuchen Sie es später erneut, oder laden Sie die Seite neu.",
|
||||
"take-loan-success": "Ausleihe erfolgreich abgeholt",
|
||||
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
|
||||
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
|
||||
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
|
||||
}
|
||||
92
FrontendV2/src/utils/i18n/locales/en/en.json
Normal file
92
FrontendV2/src/utils/i18n/locales/en/en.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"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",
|
||||
"contactPage_successHeadline": "Message sent successfully",
|
||||
"contactPage_successText": "Thank you for contacting us. We will get back to you as soon as possible.",
|
||||
"contactPage_errorHeadline": "Error sending message",
|
||||
"contactPage_errorText": "An error occurred while sending your message. Please try again later.",
|
||||
"contactPage_sendButton": "Send message",
|
||||
"contactPage_messageLabel": "Message",
|
||||
"contactPage_messagePlaceholder": "Enter your message here...",
|
||||
"contactPage_messageErrorText": "This field cannot be empty.",
|
||||
"contact": "Contact",
|
||||
"serverError": "Server error. Please try again later, or refresh the page.",
|
||||
"take": "Take",
|
||||
"return": "Return",
|
||||
"take-loan-success": "Loan taken successfully",
|
||||
"return-loan-success": "Loan returned successfully",
|
||||
"network-error": "Network error. Please contact the administrator.",
|
||||
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
@@ -21,7 +22,12 @@
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
26
FrontendV2/tsconfig.node.json
Normal file
26
FrontendV2/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr(), tailwindcss()],
|
||||
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8001,
|
||||
@@ -1,276 +0,0 @@
|
||||
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" })
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
24
admin/.gitignore
vendored
Normal file
24
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
19
admin/Dockerfile
Normal file
19
admin/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:18 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;"]
|
||||
13
admin/index.html
Normal file
13
admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/user-star.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Adminpanel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
admin/nginx.conf
Normal file
26
admin/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
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:8004/;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1898
frontend/package-lock.json → admin/package-lock.json
generated
1898
frontend/package-lock.json → admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -10,14 +10,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.26.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"split-lines": "^3.0.0",
|
||||
@@ -39,6 +44,7 @@
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"vite": "^7.1.0"
|
||||
"vite": "^7.1.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
1
admin/public/user-star.svg
Normal file
1
admin/public/user-star.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-star-icon lucide-user-star"><path d="M16.051 12.616a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.866l-1.156-1.153a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z"/><path d="M8 15H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 635 B |
1
admin/src/App.css
Normal file
1
admin/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
12
admin/src/App.tsx
Normal file
12
admin/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import "./App.css";
|
||||
import Layout from "./Layout/Layout";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Layout />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
97
admin/src/Layout/Dashboard.tsx
Normal file
97
admin/src/Layout/Dashboard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
|
||||
import Sidebar from "./Sidebar";
|
||||
import UserTable from "../components/UserTable";
|
||||
import ItemTable from "../components/ItemTable";
|
||||
import LoanTable from "../components/LoanTable";
|
||||
import APIKeyTable from "@/components/APIKeyTable";
|
||||
import { MoveLeft } from "lucide-react";
|
||||
|
||||
type DashboardProps = {
|
||||
onLogout?: () => void;
|
||||
};
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
||||
const userName = localStorage.getItem("userName") || "Admin";
|
||||
|
||||
const [activeView, setActiveView] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const raw = window.location.pathname.slice(1);
|
||||
if (raw) {
|
||||
setActiveView(decodeURIComponent(raw));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync URL when activeView changes, without reloading
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!activeView) return;
|
||||
const desired = `/${encodeURIComponent(activeView)}`;
|
||||
if (window.location.pathname !== desired) {
|
||||
window.history.replaceState(null, "", desired);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
return (
|
||||
<Flex h="100vh">
|
||||
<Sidebar
|
||||
viewAusleihen={() => setActiveView("Ausleihen")}
|
||||
viewGegenstaende={() => setActiveView("Gegenstände")}
|
||||
viewSchliessfaecher={() => setActiveView("Schließfächer")}
|
||||
viewUser={() => setActiveView("User")}
|
||||
viewAPI={() => setActiveView("API")}
|
||||
/>
|
||||
<Box flex="1" display="flex" flexDirection="column">
|
||||
<Flex
|
||||
as="header"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={6}
|
||||
py={4}
|
||||
borderBottom="1px"
|
||||
borderColor="gray.200"
|
||||
bg="gray.900"
|
||||
>
|
||||
<Heading size="xl">Dashboard</Heading>
|
||||
<Flex align="center" gap={6}>
|
||||
<Text fontSize="sm" color="white">
|
||||
Willkommen {userName}, im Admin-Dashboard!
|
||||
</Text>
|
||||
<Button variant="solid" onClick={onLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box as="main" flex="1" p={6}>
|
||||
{activeView === "" && (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={3}
|
||||
p={4}
|
||||
border="1px dashed"
|
||||
borderColor="gray.300"
|
||||
borderRadius="md"
|
||||
bg="gray.50"
|
||||
color="gray.700"
|
||||
fontSize="lg"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
<MoveLeft size={20} />
|
||||
Bitte wählen Sie eine Ansicht aus.
|
||||
</Flex>
|
||||
)}
|
||||
{activeView === "User" && <UserTable />}
|
||||
{activeView === "Ausleihen" && <LoanTable />}
|
||||
{activeView === "Gegenstände" && <ItemTable />}
|
||||
{activeView === "API" && <APIKeyTable />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
52
admin/src/Layout/Layout.tsx
Normal file
52
admin/src/Layout/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import Dashboard from "./Dashboard";
|
||||
import Login from "./Login";
|
||||
import Cookies from "js-cookie";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Cookies.get("token")) {
|
||||
const verifyToken = async () => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/user-mgmt/verify-token`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
Cookies.remove("token");
|
||||
setIsLoggedIn(false);
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
verifyToken();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove("token");
|
||||
window.location.pathname = "/";
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
{isLoggedIn ? (
|
||||
<Dashboard onLogout={() => handleLogout()} />
|
||||
) : (
|
||||
<Login onSuccess={() => setIsLoggedIn(true)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
68
admin/src/Layout/Login.tsx
Normal file
68
admin/src/Layout/Login.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { loginFunc } from "@/utils/loginUser";
|
||||
import MyAlert from "../components/myChakra/MyAlert";
|
||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
|
||||
const handleLogin = async () => {
|
||||
const result = await loginFunc(username, password);
|
||||
if (!result.success) {
|
||||
setErrorMsg(result.message);
|
||||
setErrorDsc(result.description);
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
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>Login</Card.Title>
|
||||
<Card.Description>
|
||||
Bitte unten Ihre Admin Zugangsdaten eingeben.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>username</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>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.Root>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
122
admin/src/Layout/Sidebar.tsx
Normal file
122
admin/src/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
type SidebarProps = {
|
||||
viewAusleihen: () => void;
|
||||
viewGegenstaende: () => void;
|
||||
viewSchliessfaecher: () => void;
|
||||
viewUser: () => void;
|
||||
viewAPI: () => void;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
viewAusleihen,
|
||||
viewGegenstaende,
|
||||
viewUser,
|
||||
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 (
|
||||
<Box
|
||||
as="aside"
|
||||
w="180px"
|
||||
minH="100vh"
|
||||
bg="gray.800"
|
||||
color="gray.100"
|
||||
px={6}
|
||||
py={8}
|
||||
borderRight="1px solid"
|
||||
borderColor="gray.700"
|
||||
>
|
||||
<Flex direction="column" h="full">
|
||||
<Heading size="md" mb={8} letterSpacing="wide">
|
||||
Borrow System
|
||||
</Heading>
|
||||
|
||||
<VStack align="stretch" gap={4} fontSize="sm">
|
||||
<Link
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
_hover={{ bg: "gray.700", textDecoration: "none" }}
|
||||
onClick={viewUser}
|
||||
>
|
||||
Benutzer
|
||||
</Link>
|
||||
<Link
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
_hover={{ bg: "gray.700", textDecoration: "none" }}
|
||||
onClick={viewAusleihen}
|
||||
>
|
||||
Ausleihen
|
||||
</Link>
|
||||
<Link
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
_hover={{ bg: "gray.700", textDecoration: "none" }}
|
||||
onClick={viewGegenstaende}
|
||||
>
|
||||
Gegenstände
|
||||
</Link>
|
||||
<Link
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
_hover={{ bg: "gray.700", textDecoration: "none" }}
|
||||
onClick={viewAPI}
|
||||
>
|
||||
API Keys
|
||||
</Link>
|
||||
</VStack>
|
||||
|
||||
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
||||
<Text mb={2}>© 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>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
3
admin/src/States/Atoms.tsx
Normal file
3
admin/src/States/Atoms.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const testAtom = atom<number>(0);
|
||||
36
admin/src/States/README.md
Normal file
36
admin/src/States/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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
admin/src/assets/react.svg
Normal file
1
admin/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
224
admin/src/components/APIKeyTable.tsx
Normal file
224
admin/src/components/APIKeyTable.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Heading,
|
||||
} from "@chakra-ui/react";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import { Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useState, useEffect } from "react";
|
||||
import { deleteAPKey } from "@/utils/userActions";
|
||||
import AddAPIKey from "./AddAPIKey";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
type Items = {
|
||||
id: number;
|
||||
api_key: string;
|
||||
entry_name: string;
|
||||
entry_created_at: string;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
|
||||
const APIKeyTable: React.FC = () => {
|
||||
const [items, setItems] = useState<Items[]>([]);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [reload, setReload] = useState(false);
|
||||
const [addAPIForm, setAddAPIForm] = useState(false);
|
||||
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/api-data/get-api-keys`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
setError("error", "Failed to fetch items", "There is an error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData().then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setItems(data);
|
||||
}
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action toolbar */}
|
||||
<HStack
|
||||
mb={4}
|
||||
gap={3}
|
||||
justify="flex-start"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Tooltip content="API Keys neu laden" openDelay={300}>
|
||||
<IconButton
|
||||
aria-label="Refresh API Keys"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
onClick={() => setReload(!reload)}
|
||||
>
|
||||
<RefreshCcwDot size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Neuen API Key hinzufügen" openDelay={300}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorPalette="teal"
|
||||
variant="solid"
|
||||
rounded="md"
|
||||
fontWeight="semibold"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", bg: "colorPalette.600" }}
|
||||
_active={{ bg: "colorPalette.700" }}
|
||||
onClick={() => {
|
||||
setAddAPIForm(true);
|
||||
}}
|
||||
>
|
||||
<CirclePlus size={18} style={{ marginRight: 6 }} />
|
||||
Neuen API Key hinzufügen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
{/* End action toolbar */}
|
||||
|
||||
<Heading marginBottom={4} size="2xl">
|
||||
API Keys
|
||||
</Heading>
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{addAPIForm && (
|
||||
<AddAPIKey
|
||||
onClose={() => {
|
||||
setAddAPIForm(false);
|
||||
setReload(!reload);
|
||||
}}
|
||||
alert={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table.Root
|
||||
size="sm"
|
||||
striped
|
||||
w="100%"
|
||||
// table-layout: auto => Spaltenbreite nach Content; volle Breite nutzen
|
||||
style={{ tableLayout: "auto" }}
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>API Key</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Name</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader whiteSpace="nowrap">
|
||||
<strong>Eintrag erstellt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader whiteSpace="nowrap">
|
||||
<strong>Zuletzt benutzt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Aktionen</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
|
||||
<Table.Cell fontFamily="mono">{item.api_key}</Table.Cell>
|
||||
<Table.Cell>{item.entry_name}</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
{formatDateTime(item.entry_created_at)}
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
{!item.last_used_at
|
||||
? "Nie benutzt"
|
||||
: formatDateTime(item.last_used_at)}
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
<Button
|
||||
onClick={() =>
|
||||
deleteAPKey(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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIKeyTable;
|
||||
105
admin/src/components/AddAPIKey.tsx
Normal file
105
admin/src/components/AddAPIKey.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Field,
|
||||
Input,
|
||||
Stack,
|
||||
InputGroup,
|
||||
Span,
|
||||
} from "@chakra-ui/react";
|
||||
import { createAPIentry } from "@/utils/userActions";
|
||||
import { useState } from "react";
|
||||
|
||||
type AddAPIKeyProps = {
|
||||
onClose: () => void;
|
||||
alert: (
|
||||
status: "success" | "error",
|
||||
message: string,
|
||||
description: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<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.Header>
|
||||
<Card.Title>Neuen API Key erstellen</Card.Title>
|
||||
<Card.Description>
|
||||
Füllen Sie das folgende Formular aus, um einen API Key zu erstellen.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<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.Label>Name</Field.Label>
|
||||
<Input id="name" type="text" />
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={async () => {
|
||||
const apiKey =
|
||||
(
|
||||
document.getElementById("apiKey") as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const name =
|
||||
(
|
||||
document.getElementById("name") as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
|
||||
if (!apiKey || !name) return;
|
||||
|
||||
const res = await createAPIentry(apiKey, name);
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"API Key erstellt",
|
||||
"Der API Key wurde erfolgreich erstellt."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler beim Erstellen des API Keys",
|
||||
res.message ||
|
||||
"Beim Erstellen des API Keys ist ein Fehler aufgetreten. (frontend bug)"
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAPIKey;
|
||||
149
admin/src/components/AddForm.tsx
Normal file
149
admin/src/components/AddForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Field,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
Checkbox,
|
||||
} from "@chakra-ui/react";
|
||||
import { createUser } from "@/utils/userActions";
|
||||
|
||||
type AddFormProps = {
|
||||
onClose: () => void;
|
||||
alert: (
|
||||
status: "success" | "error",
|
||||
message: string,
|
||||
description: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
||||
const [admin, setAdmin] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Neuen Nutzer erstellen</Card.Title>
|
||||
<Card.Description>
|
||||
Füllen Sie das folgende Formular aus, um einen Nutzer zu
|
||||
erstellen.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Body>
|
||||
<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 */}
|
||||
<Checkbox.Root
|
||||
checked={admin}
|
||||
onCheckedChange={(e: any) => setAdmin(Boolean(e?.checked ?? e))}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<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 =
|
||||
(
|
||||
document.getElementById("firstname") as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const lastname =
|
||||
(
|
||||
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
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"Nutzer erstellt",
|
||||
"Der Nutzer wurde erfolgreich erstellt."
|
||||
);
|
||||
onClose();
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddForm;
|
||||
95
admin/src/components/AddItemForm.tsx
Normal file
95
admin/src/components/AddItemForm.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||
import { createItem } from "@/utils/userActions";
|
||||
|
||||
type AddItemFormProps = {
|
||||
onClose: () => void;
|
||||
alert: (
|
||||
status: "success" | "error",
|
||||
message: string,
|
||||
description: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
|
||||
return (
|
||||
<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.Header>
|
||||
<Card.Title>Neuen Gegenstand erstellen</Card.Title>
|
||||
<Card.Description>
|
||||
Füllen Sie das folgende Formular aus, um einen Gegenstand zu
|
||||
erstellen.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>Gegenstandsname (muss einzigartig sein)</Field.Label>
|
||||
<Input id="item_name" placeholder="z.B. Laptop" />
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>Schließfachnummer</Field.Label>
|
||||
<Input id="safe_nr" placeholder="Nummer 1 - 6" />
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
|
||||
<Input
|
||||
id="can_borrow_role"
|
||||
type="number"
|
||||
placeholder="Zahl (1 - 6)"
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end" gap="2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={async () => {
|
||||
const name =
|
||||
(
|
||||
document.getElementById("item_name") as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const role = Number(
|
||||
(document.getElementById("can_borrow_role") as HTMLInputElement)
|
||||
?.value
|
||||
);
|
||||
const safeNrValue = (
|
||||
document.getElementById("safe_nr") as HTMLInputElement
|
||||
)?.value.trim();
|
||||
|
||||
const safeNr = safeNrValue === "" ? null : safeNrValue;
|
||||
|
||||
if (!name || Number.isNaN(role)) return;
|
||||
|
||||
const res = await createItem(name, role, safeNr);
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"Gegenstand erstellt",
|
||||
"Der Gegenstand wurde erfolgreich erstellt."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler",
|
||||
res.message ||
|
||||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddItemForm;
|
||||
122
admin/src/components/ChangePWform.tsx
Normal file
122
admin/src/components/ChangePWform.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Field, Input, Stack, Alert } from "@chakra-ui/react";
|
||||
import { changePW } from "@/utils/userActions";
|
||||
import { useState } from "react";
|
||||
|
||||
type ChangePWformProps = {
|
||||
onClose: () => void;
|
||||
alert: (
|
||||
status: "success" | "error",
|
||||
message: string,
|
||||
description: string
|
||||
) => void;
|
||||
username: string;
|
||||
};
|
||||
|
||||
const ChangePWform: React.FC<ChangePWformProps> = ({
|
||||
onClose,
|
||||
alert,
|
||||
username,
|
||||
}) => {
|
||||
const [showSubAlert, setShowSubAlert] = useState(false);
|
||||
const [subAlertMessage, setSubAlertMessage] = useState("");
|
||||
|
||||
const subAlert = (message: string) => {
|
||||
setSubAlertMessage(message);
|
||||
setShowSubAlert(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<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.Header>
|
||||
<Card.Title>Passwort ändern</Card.Title>
|
||||
<Card.Description>
|
||||
Füllen Sie das folgende Formular aus, um das Passwort zu ändern.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>Neues Passwort</Field.Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
placeholder="Neues Passwort"
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>Neues Passwort widerholen</Field.Label>
|
||||
<Input
|
||||
id="confirm_new_password"
|
||||
type="password"
|
||||
placeholder="Wiederholen Sie das neue Passwort"
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer gap="2">
|
||||
<Stack w="full" gap="3">
|
||||
<Stack direction="row" justify="flex-end" gap="2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={async () => {
|
||||
const newPassword =
|
||||
(
|
||||
document.getElementById(
|
||||
"new_password"
|
||||
) as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const confirmNewPassword =
|
||||
(
|
||||
document.getElementById(
|
||||
"confirm_new_password"
|
||||
) as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
|
||||
if (!newPassword || newPassword !== confirmNewPassword) {
|
||||
subAlert("Passwörter stimmen nicht überein!");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await changePW(newPassword, username);
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"Passwort geändert",
|
||||
"Das Passwort wurde erfolgreich geändert."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler",
|
||||
"Das Passwort konnte nicht geändert werden."
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{showSubAlert && (
|
||||
<Alert.Root status="error">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{subAlertMessage}</Alert.Title>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePWform;
|
||||
382
admin/src/components/ItemTable.tsx
Normal file
382
admin/src/components/ItemTable.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Heading,
|
||||
Icon,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import {
|
||||
Trash2,
|
||||
RefreshCcwDot,
|
||||
CirclePlus,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
deleteItem,
|
||||
handleEditItems,
|
||||
changeSafeState,
|
||||
} from "@/utils/userActions";
|
||||
import AddItemForm from "./AddItemForm";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
type Items = {
|
||||
id: number;
|
||||
item_name: string;
|
||||
can_borrow_role: string;
|
||||
in_safe: boolean;
|
||||
safe_nr: string;
|
||||
door_key: string;
|
||||
entry_created_at: string;
|
||||
entry_updated_at: string;
|
||||
last_borrowed_person: string | null;
|
||||
currently_borrowing: string | null;
|
||||
};
|
||||
|
||||
const ItemTable: React.FC = () => {
|
||||
const [items, setItems] = useState<Items[]>([]);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [reload, setReload] = useState(false);
|
||||
const [addForm, setAddForm] = useState(false);
|
||||
|
||||
const handleItemNameChange = (id: number, value: string) => {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it))
|
||||
);
|
||||
};
|
||||
|
||||
const handleCanBorrowRoleChange = (id: number, value: string) => {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it))
|
||||
);
|
||||
};
|
||||
|
||||
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 = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/item-data/all-items`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
setError("error", "Failed to fetch items", "There is an error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData().then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setItems(data);
|
||||
}
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action toolbar */}
|
||||
<HStack
|
||||
mb={4}
|
||||
gap={3}
|
||||
justify="flex-start"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Tooltip content="Gegenstände neu laden" openDelay={300}>
|
||||
<IconButton
|
||||
aria-label="Refresh items"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
onClick={() => setReload(!reload)}
|
||||
>
|
||||
<RefreshCcwDot size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Neuen Gegenstand hinzufügen" openDelay={300}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorPalette="teal"
|
||||
variant="solid"
|
||||
rounded="md"
|
||||
fontWeight="semibold"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", bg: "colorPalette.600" }}
|
||||
_active={{ bg: "colorPalette.700" }}
|
||||
onClick={() => {
|
||||
setAddForm(true);
|
||||
}}
|
||||
>
|
||||
<CirclePlus size={18} style={{ marginRight: 6 }} />
|
||||
Neuen Gegenstand hinzufügen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
{/* End action toolbar */}
|
||||
|
||||
<Heading marginBottom={4} size="2xl">
|
||||
Gegenstände
|
||||
</Heading>
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{addForm && (
|
||||
<AddItemForm
|
||||
onClose={() => {
|
||||
setAddForm(false);
|
||||
setReload(!reload);
|
||||
}}
|
||||
alert={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* make table fill available width, like UserTable */}
|
||||
{!isLoading && (
|
||||
<Table.Root
|
||||
size="sm"
|
||||
striped
|
||||
w="100%"
|
||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Gegenstand</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Ausleih Berechtigung</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Im Schließfach</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Schließfachnummer</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Schlüssel</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Eintrag erstellt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Eintrag aktualisiert am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>LaP *</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Dav **</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Aktionen</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items.map((item) => (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemTable;
|
||||
217
admin/src/components/LoanTable.tsx
Normal file
217
admin/src/components/LoanTable.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Heading,
|
||||
Code,
|
||||
} from "@chakra-ui/react";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import { useState, useEffect } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import { Trash2, RefreshCcwDot } from "lucide-react";
|
||||
import { deleteLoan } from "@/utils/userActions";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
const LoanTable: React.FC = () => {
|
||||
const [items, setItems] = useState<Loan[]>([]);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [reload, setReload] = useState(false);
|
||||
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
type Loan = {
|
||||
id: number;
|
||||
username: string;
|
||||
loan_code: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
take_date: string;
|
||||
returned_date: string;
|
||||
created_at: string;
|
||||
loaned_items_name: string[];
|
||||
deleted: boolean;
|
||||
note: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/loan-data/all-loans`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
setError("error", "Failed to fetch loans", "There is an error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData().then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setItems(data);
|
||||
}
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action toolbar */}
|
||||
<HStack
|
||||
mb={4}
|
||||
gap={3}
|
||||
justify="flex-start"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Tooltip content="Ausleihen neu laden" openDelay={300}>
|
||||
<IconButton
|
||||
aria-label="Refresh loans"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
onClick={() => setReload(!reload)}
|
||||
>
|
||||
<RefreshCcwDot size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
{/* End action toolbar */}
|
||||
|
||||
<Heading marginBottom={4} size="2xl">
|
||||
Ausleihen
|
||||
</Heading>
|
||||
<Text>
|
||||
Die Ausleihen die rot sind, wurden gelöscht und sind nur für den Admin
|
||||
sichtbar.
|
||||
</Text>
|
||||
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<Table.Root size="sm" striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Besitzer</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Ausleih code</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Startdatum</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Enddatum</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Ausleihdatum</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Rückgabedatum</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Erstellt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Ausgeliehene Artikel</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Notiz</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Aktionen</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items.map((item) => (
|
||||
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
|
||||
<Table.Cell>{item.id}</Table.Cell>
|
||||
<Table.Cell>{item.username}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Code>{item.loan_code}</Code>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
|
||||
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
|
||||
<Table.Cell>{item.note}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button
|
||||
onClick={() =>
|
||||
deleteLoan(item.id).then((response) => {
|
||||
if (response.success) {
|
||||
setItems(items.filter((i) => i.id !== item.id));
|
||||
setError(
|
||||
"success",
|
||||
"Loan deleted",
|
||||
"The loan has been successfully deleted."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
colorPalette="red"
|
||||
size="sm"
|
||||
ml={2}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoanTable;
|
||||
367
admin/src/components/UserTable.tsx
Normal file
367
admin/src/components/UserTable.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Table,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
Input,
|
||||
HStack,
|
||||
IconButton,
|
||||
Heading,
|
||||
Switch, // neu
|
||||
} from "@chakra-ui/react";
|
||||
import { Tooltip } from "@/components/ui/tooltip";
|
||||
import { fetchUserData } from "@/utils/fetcher";
|
||||
import { Save, Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
|
||||
import { handleDelete, handleEdit } from "@/utils/userActions";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import AddForm from "./AddForm";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import ChangePWform from "./ChangePWform";
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
role: number;
|
||||
entry_created_at: string;
|
||||
entry_updated_at: string;
|
||||
};
|
||||
|
||||
const UserTable: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
const [reload, setReload] = useState(false);
|
||||
const [addForm, setAddForm] = useState(false);
|
||||
const [changePWform, setChangePWform] = useState(false);
|
||||
const [changeUsr, setChangeUsr] = useState("");
|
||||
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (userId: number, field: string, value: any) => {
|
||||
setUsers((prevUsers) =>
|
||||
prevUsers.map((user) =>
|
||||
user.id === userId
|
||||
? {
|
||||
...user,
|
||||
[field]:
|
||||
field === "role"
|
||||
? Number(value)
|
||||
: field === "is_admin"
|
||||
? value === true || value === "true" || value === 1
|
||||
: value,
|
||||
}
|
||||
: user
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (username: string) => {
|
||||
setChangeUsr(username);
|
||||
setChangePWform(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchUserData();
|
||||
if (Array.isArray(data)) {
|
||||
setUsers(data);
|
||||
} else {
|
||||
setError(
|
||||
"error",
|
||||
"Failed to load users",
|
||||
"Invalid data format received"
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch users", e);
|
||||
if (e instanceof Error) {
|
||||
setError(
|
||||
"error",
|
||||
"Failed to fetch users",
|
||||
e.message || "Unknown error"
|
||||
);
|
||||
} else {
|
||||
setError("error", "Failed to fetch users", "Unknown error");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action toolbar */}
|
||||
<HStack
|
||||
mb={4}
|
||||
gap={3}
|
||||
justify="flex-start"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Tooltip content="Benutzer neu laden" openDelay={300}>
|
||||
<IconButton
|
||||
aria-label="Refresh users"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
onClick={() => setReload(!reload)}
|
||||
>
|
||||
<RefreshCcwDot size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Neuen Nutzer hinzufügen" openDelay={300}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorPalette="teal"
|
||||
variant="solid"
|
||||
rounded="md"
|
||||
fontWeight="semibold"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: "md", bg: "colorPalette.600" }}
|
||||
_active={{ bg: "colorPalette.700" }}
|
||||
onClick={() => {
|
||||
setAddForm(true);
|
||||
}}
|
||||
>
|
||||
<CirclePlus size={18} style={{ marginRight: 6 }} />
|
||||
Neuen Nutzer hinzufügen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
{/* End action toolbar */}
|
||||
|
||||
<Heading marginBottom={4} size="2xl">
|
||||
Benutzer
|
||||
</Heading>
|
||||
{changePWform && (
|
||||
<ChangePWform
|
||||
onClose={() => {
|
||||
setChangePWform(false);
|
||||
setReload(!reload);
|
||||
}}
|
||||
alert={setError}
|
||||
username={changeUsr}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
{addForm && (
|
||||
<AddForm
|
||||
onClose={() => {
|
||||
setAddForm(false);
|
||||
setReload(!reload);
|
||||
}}
|
||||
alert={setError}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<Table.Root
|
||||
size="sm"
|
||||
striped
|
||||
w="100%"
|
||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Benutzername</strong>
|
||||
</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>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Rolle</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader whiteSpace="nowrap">
|
||||
<strong>Eintrag erstellt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader whiteSpace="nowrap">
|
||||
<strong>Eintrag aktualisiert am</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>Aktionen</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users.map((user) => (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell whiteSpace="nowrap">{user.id}</Table.Cell>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Input
|
||||
size="sm"
|
||||
value={user.first_name ?? ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(user.id, "first_name", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Input
|
||||
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
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
onChange={(e) =>
|
||||
handleInputChange(user.id, "role", e.target.value)
|
||||
}
|
||||
value={user.role}
|
||||
width="70px"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
{formatDateTime(user.entry_created_at)}
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
{formatDateTime(user.entry_updated_at)}
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleEdit(
|
||||
user.id,
|
||||
user.first_name,
|
||||
user.last_name,
|
||||
user.email,
|
||||
user.is_admin,
|
||||
Number(user.role)
|
||||
).then((response) => {
|
||||
if (response.success) {
|
||||
setError(
|
||||
"success",
|
||||
"User edited",
|
||||
"The user has been successfully edited."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
colorPalette="teal"
|
||||
size="sm"
|
||||
>
|
||||
<Save />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleDelete(user.id).then((response) => {
|
||||
if (response.success) {
|
||||
setUsers(users.filter((u) => u.id !== user.id));
|
||||
setError(
|
||||
"success",
|
||||
"User deleted",
|
||||
"The user has been successfully deleted."
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
colorPalette="red"
|
||||
size="sm"
|
||||
ml={2}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserTable;
|
||||
22
admin/src/components/myChakra/MyAlert.tsx
Normal file
22
admin/src/components/myChakra/MyAlert.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
108
admin/src/components/ui/color-mode.tsx
Normal file
108
admin/src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"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="8" />}>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
159
admin/src/components/ui/password-input.tsx
Normal file
159
admin/src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ButtonProps,
|
||||
GroupProps,
|
||||
InputProps,
|
||||
StackProps,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
Stack,
|
||||
mergeRefs,
|
||||
useControllableState,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu"
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
/**
|
||||
* The default visibility state of the password input.
|
||||
*/
|
||||
defaultVisible?: boolean
|
||||
/**
|
||||
* The controlled visibility state of the password input.
|
||||
*/
|
||||
visible?: boolean
|
||||
/**
|
||||
* Callback invoked when the visibility state changes.
|
||||
*/
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
/**
|
||||
* Custom icons for the visibility toggle button.
|
||||
*/
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends InputProps,
|
||||
PasswordVisibilityProps {
|
||||
rootProps?: GroupProps
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
PasswordInputProps
|
||||
>(function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
})
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setVisible(!visible)
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs(ref, inputRef)}
|
||||
type={visible ? "text" : "password"}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
})
|
||||
|
||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me="-2"
|
||||
aspectRatio="square"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
height="calc(100% - {spacing.2})"
|
||||
aria-label="Toggle password visibility"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PasswordStrengthMeterProps
|
||||
>(function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props
|
||||
|
||||
const percent = (value / max) * 100
|
||||
const { label, colorPalette } = getColorPalette(percent)
|
||||
|
||||
return (
|
||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
||||
<HStack width="full" {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height="1"
|
||||
flex="1"
|
||||
rounded="sm"
|
||||
data-selected={index < value ? "" : undefined}
|
||||
layerStyle="fill.subtle"
|
||||
colorPalette="gray"
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: "fill.solid",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
||||
</Stack>
|
||||
)
|
||||
})
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: "Low", colorPalette: "red" }
|
||||
case percent < 66:
|
||||
return { label: "Medium", colorPalette: "orange" }
|
||||
default:
|
||||
return { label: "High", colorPalette: "green" }
|
||||
}
|
||||
}
|
||||
15
admin/src/components/ui/provider.tsx
Normal file
15
admin/src/components/ui/provider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
|
||||
import {
|
||||
ColorModeProvider,
|
||||
type ColorModeProviderProps,
|
||||
} from "./color-mode"
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
)
|
||||
}
|
||||
43
admin/src/components/ui/toaster.tsx
Normal file
43
admin/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
46
admin/src/components/ui/tooltip.tsx
Normal file
46
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
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>
|
||||
)
|
||||
},
|
||||
)
|
||||
4
admin/src/config/api.config.ts
Normal file
4
admin/src/config/api.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
1
admin/src/index.css
Normal file
1
admin/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
13
admin/src/main.tsx
Normal file
13
admin/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Provider } from "@/components/ui/provider";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Provider>
|
||||
<App />
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
12
admin/src/utils/fetcher.ts
Normal file
12
admin/src/utils/fetcher.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
export const fetchUserData = async () => {
|
||||
const response = await fetch(`${API_BASE}/api/admin/user-data/users`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
53
admin/src/utils/loginUser.ts
Normal file
53
admin/src/utils/loginUser.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
export type LoginSuccess = { success: true };
|
||||
export type LoginFailure = {
|
||||
success: false;
|
||||
message: string;
|
||||
description: string;
|
||||
};
|
||||
export type LoginResult = LoginSuccess | LoginFailure;
|
||||
|
||||
export const loginFunc = async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<LoginResult> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/admin/user-mgmt/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Login failed!",
|
||||
description: "Invalid username or password.",
|
||||
};
|
||||
}
|
||||
|
||||
// Successful login
|
||||
const data = await response.json();
|
||||
Cookies.set("token", data.token);
|
||||
localStorage.setItem("userName", data.first_name);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error logging in:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Login failed!",
|
||||
description: "Server error.",
|
||||
};
|
||||
}
|
||||
};
|
||||
292
admin/src/utils/userActions.ts
Normal file
292
admin/src/utils/userActions.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
export const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/user-data/delete-user/${userId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete user");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const handleEdit = async (
|
||||
userId: number,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string,
|
||||
is_admin: boolean,
|
||||
role: number
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/user-data/edit-user/${userId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
first_name,
|
||||
last_name,
|
||||
role,
|
||||
email,
|
||||
is_admin,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to edit user");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error editing user:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async (
|
||||
username: string,
|
||||
role: number,
|
||||
password: string,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string,
|
||||
isAdmin: boolean
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/user-data/create-user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
role,
|
||||
password,
|
||||
isAdmin,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const changePW = async (newPassword: string, username: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/user-data/change-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ username, password: newPassword }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to change password");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error changing password:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteLoan = async (loanId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/loan-data/delete-loan/${loanId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete loan");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting loan:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteItem = async (itemId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/item-data/delete-item/${itemId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete item");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting item:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const createItem = async (
|
||||
item_name: string,
|
||||
can_borrow_role: number,
|
||||
lockerNumber: string | null
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/item-data/create-item`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ item_name, can_borrow_role, lockerNumber }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error creating item:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const handleEditItems = async (
|
||||
itemId: number,
|
||||
item_name: string,
|
||||
safe_nr: string | null,
|
||||
door_key: string | null,
|
||||
can_borrow_role: string
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to edit item");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error editing item:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const changeSafeState = async (itemId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/item-data/change-safe-state/${itemId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to change safe state");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error changing safe state:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const createAPIentry = async (apiKey: string, name: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/api-data/create-api-key`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ apiKey, entryName: name }),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Fehler beim Erstellen des API Keys. Achten Sie darauf, dass alle Felder ausgefüllt sind und der API Key nicht doppelt vergeben wird.",
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error creating API entry:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAPKey = async (apiKeyId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/api-data/delete-api-key/${apiKeyId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete API key");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting API key:", error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
7
admin/src/utils/userFuncs.ts
Normal file
7
admin/src/utils/userFuncs.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const formatDateTime = (value: string | null | undefined) => {
|
||||
if (!value) return "N/A";
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!m) return "N/A";
|
||||
const [, y, M, d, h, min] = m;
|
||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||
};
|
||||
11
admin/tailwind.config.js
Normal file
11
admin/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
// add other paths if needed
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
33
admin/tsconfig.app.json
Normal file
33
admin/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
admin/tsconfig.json
Normal file
7
admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
16
admin/vite.config.ts
Normal file
16
admin/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8003,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import express from "express";
|
||||
import dotenv from "dotenv";
|
||||
import {
|
||||
getItemsFromDatabaseV2,
|
||||
changeInSafeStateV2,
|
||||
setReturnDateV2,
|
||||
setTakeDateV2,
|
||||
getLoanByCodeV2,
|
||||
} from "../services/database.js";
|
||||
|
||||
dotenv.config();
|
||||
const router = express.Router();
|
||||
|
||||
// Route for API to get ALL items from the database
|
||||
router.get("/items/:key", async (req, res) => {
|
||||
if (req.params.key === process.env.ADMIN_ID) {
|
||||
const result = await getItemsFromDatabaseV2();
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch items" });
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route for API to control the position of an item
|
||||
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
|
||||
if (req.params.key === process.env.ADMIN_ID) {
|
||||
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" });
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => {
|
||||
if (req.params.key === process.env.ADMIN_ID) {
|
||||
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
|
||||
router.post("/setReturnDate/:key/:loan_code", async (req, res) => {
|
||||
if (req.params.key === process.env.ADMIN_ID) {
|
||||
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" });
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route for API to set the take away date
|
||||
router.post("/setTakeDate/:key/:loan_code", async (req, res) => {
|
||||
if (req.params.key === process.env.ADMIN_ID) {
|
||||
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" });
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,80 +0,0 @@
|
||||
-- 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 `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`)
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -1,32 +0,0 @@
|
||||
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 = 8002;
|
||||
|
||||
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!");
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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,12 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /backend
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8002
|
||||
|
||||
EXPOSE 8004
|
||||
CMD ["npm", "start"]
|
||||
11
backendV2/info.json
Normal file
11
backendV2/info.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"backend-info": {
|
||||
"version": "v2.1.1 (demo)"
|
||||
},
|
||||
"frontend-info": {
|
||||
"version": "v2.1.2 (demo)"
|
||||
},
|
||||
"admin-panel-info": {
|
||||
"version": "v1.3.2 (demo)"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user