Compare commits
132 Commits
b065f234bc
...
debian12_v
Author | SHA1 | Date | |
---|---|---|---|
53cf686746 | |||
49d4d13afc | |||
45fa095eaf | |||
23be7e12c7 | |||
893a7e041d | |||
6ea1ff799c | |||
5131266242 | |||
bb17bc735c | |||
d4b2e8db20 | |||
af7d15c97a | |||
04453fd885 | |||
bf36a6605f | |||
8f9696991f | |||
9cad1e8b6b | |||
880029a0cf | |||
b52d707bf5 | |||
32abe60d98 | |||
b6ebfcd631 | |||
ea965971f1 | |||
7ecd9dad3f | |||
eff1f61422 | |||
a4c0323100 | |||
fc755edadf | |||
7f9ed23a86 | |||
21b152ef2b | |||
0fca896cc2 | |||
f83f321876 | |||
b9d637665c | |||
378720b235 | |||
451a5a92dd | |||
85b519c5b1 | |||
49f4ba8483 | |||
27db4c7390 | |||
1b08344a0f | |||
ea5d31384a | |||
7f5f464841 | |||
ab93c9959d | |||
679ef7dcbd | |||
c3572a3d70 | |||
4fc60a08d9 | |||
a0be100db5 | |||
2143d53eb5 | |||
c4d5ebd9ae | |||
5159877d8d | |||
938e9000f8 | |||
558a8330af | |||
b3ddfd9aa5 | |||
755ebfd06b | |||
e198fce791 | |||
ad2395f98b | |||
8341404f45 | |||
51baf8d970 | |||
5291752403 | |||
d18465ff1d | |||
b36f1ba9ba | |||
c0ae12185a | |||
68f13f369c | |||
a24b2697b0 | |||
13a654561e | |||
5a058de2f0 | |||
b8f13a37fd | |||
423075e746 | |||
99810d2b7d | |||
a1972f26d3 | |||
866f860d5a | |||
16e1dca43c | |||
c47c311ecd | |||
784bd1e8ce | |||
bd504f7817 | |||
b0914314bb | |||
ae7aec8d3b | |||
3f9381a80c | |||
1826086186 | |||
af4abfc8f9 | |||
ba0f06e104 | |||
48c16350b7 | |||
b217769961 | |||
769d1117eb | |||
c77bef5cf3 | |||
217803ba8f | |||
8fb70ccccd | |||
311de4f78b | |||
7b36514e27 | |||
7be418ad75 | |||
478f03452d | |||
a932144e94 | |||
36ad60b782 | |||
7faf95188d | |||
79df00a17e | |||
d1625d7e47 | |||
a8377e5ec3 | |||
8f1d401aa1 | |||
226a267ccb | |||
fd9496645a | |||
33bb64f44b | |||
e4467dba32 | |||
2cf82c8dcb | |||
bcf3cc08bb | |||
cd2b0e8e42 | |||
4b79583574 | |||
410923af92 | |||
c779a31bfa | |||
24c405386b | |||
d5296bd3fa | |||
c33f3e1101 | |||
67dd74d3d3 | |||
d82cde55cc | |||
2b7f6e8e17 | |||
ccceb5840c | |||
ff219d850b | |||
ef19592b32 | |||
3ee2f6b670 | |||
8291968e7a | |||
09af4c760c | |||
3fd0fd9584 | |||
27984ebac8 | |||
3d4aab74d5 | |||
4076630eec | |||
6025212e93 | |||
de554048eb | |||
e1d79d2c79 | |||
2480bfab89 | |||
64bfbecd84 | |||
d1494473ef | |||
cbcf282ca3 | |||
c389b38cf5 | |||
4080d171cf | |||
6d4afa46d7 | |||
ffc8fbcefc | |||
a1435e3280 | |||
61c0c8ac96 | |||
6c56c3e46d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -111,4 +111,6 @@ backend/public/uploads/
|
|||||||
# API keys and secrets (additional protection)
|
# API keys and secrets (additional protection)
|
||||||
config/
|
config/
|
||||||
secrets/
|
secrets/
|
||||||
keys/
|
keys/
|
||||||
|
|
||||||
|
ToDo.txt
|
22
Docs/CHANGELOG.md
Normal file
22
Docs/CHANGELOG.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Changelog
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
## Current hosted version
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
> No changelog available.
|
||||||
|
|
||||||
|
## Upcoming changes
|
||||||
|
|
||||||
|
v1.2
|
||||||
|
|
||||||
|
### Fixes and improvements
|
||||||
|
|
||||||
|
- Implement user roles and permissions
|
||||||
|
- Improve form validation and error handling
|
||||||
|
- Add loading indicators for async actions
|
||||||
|
- Optimize performance for large datasets
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- Admin panel for managing users, permissions and all of the system settings and database
|
11
Docs/HELP.md
Normal file
11
Docs/HELP.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Hilfe Seite
|
||||||
|
|
||||||
|
Hier finden Sie Informationen zur Verwendung des Systems.
|
||||||
|
|
||||||
|
## Unerwartete Probleme
|
||||||
|
|
||||||
|
Falls unerwartetet Probleme im Web oder im Safe auftreten sollten, können Sie den Support via Teams kontaktieren.
|
||||||
|
|
||||||
|
**Kontaktpersonen:**
|
||||||
|
- Theis Gaedigk (Web & Safe)
|
||||||
|
- Niklas Brunke (Safe)
|
@@ -0,0 +1,5 @@
|
|||||||
|
# Backend API Documentation
|
||||||
|
|
||||||
|
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.
|
@@ -1,283 +1,210 @@
|
|||||||
# Borrow System Backend API
|
# Backend API docs (apiV2)
|
||||||
|
|
||||||
Base URL: `http://localhost:8002`
|
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
|
||||||
|
|
||||||
- App server: [backend/server.js](backend/server.js)
|
On this page you will learn how my API works.
|
||||||
- Auth/JWT: [`authenticate`](backend/services/tokenService.js), [`generateToken`](backend/services/tokenService.js)
|
|
||||||
- App API (JWT): [backend/routes/api.js](backend/routes/api.js)
|
## General information
|
||||||
- Admin API (key): [backend/routes/apiV2.js](backend/routes/apiV2.js)
|
|
||||||
- DB layer: [backend/services/database.js](backend/services/database.js)
|
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` which is for my web frontend, because this file works together with my JWT token service.
|
||||||
- Schema: [backend/scheme.sql](backend/scheme.sql)
|
|
||||||
|
But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
|
||||||
|
|
||||||
|
But first you have to get an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
- Frontend: `https://insta.the1s.de`
|
||||||
|
- Backend: `https://backend.insta.the1s.de`
|
||||||
|
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
|
||||||
|
|
||||||
|
You can see the status of this and all my other services at `https://status.the1s.de`.
|
||||||
|
|
||||||
|
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Most endpoints under `/api` require a Bearer JWT.
|
All endpoints require an API key as a path parameter named `:key`.
|
||||||
|
|
||||||
1. Login to get a token
|
Example: `/apiV2/items/:key`
|
||||||
|
|
||||||
- POST /api/login
|
If the key is missing or invalid, the API responds with `401 Unauthorized`.
|
||||||
- Body: `{ "username": string, "password": string }`
|
|
||||||
- Response 200: `{ "message": "Login successful", "token": string }`
|
|
||||||
- Response 401: `{ "message": "Invalid credentials" }`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s -X POST http://localhost:8002/api/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"alice","password":"password1"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Use the token for all protected endpoints
|
|
||||||
|
|
||||||
- Header: `Authorization: Bearer <token>`
|
|
||||||
|
|
||||||
The middleware [`authenticate`](backend/services/tokenService.js) verifies tokens and attaches `req.user = { username, role }`.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
|
|
||||||
- SECRET_KEY: HMAC secret for JWT
|
|
||||||
- DB_HOST, DB_USER, DB_PASSWORD, DB_NAME: MySQL connection
|
|
||||||
- ADMIN_ID: Admin API key for /apiV2
|
|
||||||
|
|
||||||
## Data model (items)
|
|
||||||
|
|
||||||
Items (as returned by endpoints) have:
|
|
||||||
|
|
||||||
- `id`: number
|
|
||||||
- `item_name`: string
|
|
||||||
- `can_borrow_role`: number (minimum role required)
|
|
||||||
- `inSafe`: 0/1 (not in locker / in locker)
|
|
||||||
|
|
||||||
See the full schema in [backend/scheme.sql](backend/scheme.sql).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## App API (JWT) — /api
|
## Endpoints
|
||||||
|
|
||||||
All routes below require `Authorization: Bearer <token>` unless noted.
|
### 1) Get all items
|
||||||
|
|
||||||
### GET /api/items
|
GET `/apiV2/items/:key`
|
||||||
|
|
||||||
Returns items filtered by the user role:
|
Returns a list of all items wrapped in a `data` object.
|
||||||
|
|
||||||
- role == 0: all items
|
Example request:
|
||||||
- role > 0: items with `can_borrow_role >= role`
|
|
||||||
|
|
||||||
Implements: [`getItemsFromDatabase`](backend/services/database.js)
|
```
|
||||||
|
GET https://backend.insta.the1s.de/apiV2/items/12345
|
||||||
Response 200:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{ "id": 1, "item_name": "Laptop", "can_borrow_role": 1, "inSafe": 1 },
|
|
||||||
...
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example response:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
curl -s http://localhost:8002/api/items \
|
{
|
||||||
-H "Authorization: Bearer $TOKEN"
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"item_name": "DJI 1er Mikro",
|
||||||
|
"can_borrow_role": 4,
|
||||||
|
"inSafe": 1,
|
||||||
|
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/loans
|
Fields:
|
||||||
|
|
||||||
Returns all loans.
|
- `id`: Unique identifier
|
||||||
|
- `item_name`: Item name
|
||||||
|
- `can_borrow_role`: Role allowed to borrow
|
||||||
|
- `inSafe`: 1 if in locker, 0 otherwise
|
||||||
|
- `entry_created_at`: Creation timestamp
|
||||||
|
|
||||||
Implements: [`getLoansFromDatabase`](backend/services/database.js)
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
Response 200:
|
---
|
||||||
|
|
||||||
```json
|
### 2) Change item safe state
|
||||||
[
|
|
||||||
{
|
POST `/apiV2/controlInSafe/:key/:itemId/:state`
|
||||||
"id": 1,
|
|
||||||
"username": "alice",
|
Updates `inSafe` (locker) state of an item.
|
||||||
"loan_code": 1001,
|
|
||||||
"start_date": "2025-08-01T09:00:00.000Z",
|
- `state` must be `"1"` (in safe) or `"0"` (not in safe)
|
||||||
"end_date": "2025-08-10T09:00:00.000Z",
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response (shape depends on database service):
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "data": { /* update result */ } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 200 on success
|
||||||
|
- 400 if `state` is invalid
|
||||||
|
- 500 on failure
|
||||||
|
|
||||||
|
**You can get the item id on the admin panel, from your system administrator.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) Get loan by code
|
||||||
|
|
||||||
|
GET `/apiV2/getLoanByCode/:key/:loan_code`
|
||||||
|
|
||||||
|
Retrieves the details of a specific loan.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"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,
|
||||||
"returned_date": null,
|
"returned_date": null,
|
||||||
"created_at": "2025-08-01T09:00:00.000Z",
|
"created_at": "2025-08-20T11:23:40.000Z",
|
||||||
"loaned_items_id": [1, 2],
|
"loaned_items_id": [8, 9],
|
||||||
"loaned_items_name": ["Laptop", "Projector"]
|
"loaned_items_name": ["SD Karten", "Kameragimbal"]
|
||||||
}
|
}
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/userLoans
|
|
||||||
|
|
||||||
Returns loans for the authenticated user.
|
|
||||||
|
|
||||||
Implements: [`getUserLoansFromDatabase`](backend/services/database.js)
|
|
||||||
|
|
||||||
Response 200:
|
|
||||||
|
|
||||||
- On success: `Loan[]`
|
|
||||||
- If none found: `"No loans found for this user"` (string)
|
|
||||||
|
|
||||||
Tip: Treat a non-array response as “no loans”.
|
|
||||||
|
|
||||||
### DELETE /api/deleteLoan/:id
|
|
||||||
|
|
||||||
Deletes a loan by numeric ID.
|
|
||||||
|
|
||||||
Implements: [`deleteLoanFromDatabase`](backend/services/database.js)
|
|
||||||
|
|
||||||
- 200: `{ "message": "Loan deleted successfully" }`
|
|
||||||
- 500: `{ "message": "Failed to delete loan" }`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s -X DELETE http://localhost:8002/api/deleteLoan/42 \
|
|
||||||
-H "Authorization: Bearer $TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /api/borrowableItems
|
|
||||||
|
|
||||||
Returns items available in the given time range (excludes items with overlapping loans). Also enforces role filtering.
|
|
||||||
|
|
||||||
Implements: [`getBorrowableItemsFromDatabase`](backend/services/database.js)
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "startDate": "2025-08-01T09:00:00Z", "endDate": "2025-08-02T09:00:00Z" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- 200: `Item[]`
|
|
||||||
- 400: `{ "message": "startDate and endDate are required" }`
|
|
||||||
- 500: `{ "message": "Failed to fetch borrowable items" }`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s -X POST http://localhost:8002/api/borrowableItems \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"startDate":"2025-08-01T09:00:00Z","endDate":"2025-08-02T09:00:00Z"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /api/createLoan
|
|
||||||
|
|
||||||
Creates a loan for the authenticated user.
|
|
||||||
|
|
||||||
Implements: [`createLoanInDatabase`](backend/services/database.js)
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [1, 2, 3], // array of item IDs (required)
|
|
||||||
"startDate": "2025-08-01T09:00:00Z", // required
|
|
||||||
"endDate": "2025-08-02T09:00:00Z" // required
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Status:
|
||||||
|
|
||||||
- IDs are coerced to numbers; invalid entries are dropped.
|
- 200 on success
|
||||||
- Date range must be valid and `startDate < endDate`.
|
- 404 if not found
|
||||||
- Overlaps with existing loans cause 409 Conflict.
|
|
||||||
- On success, returns the generated `loanCode`.
|
|
||||||
|
|
||||||
Responses:
|
|
||||||
|
|
||||||
- 201:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Loan created successfully",
|
|
||||||
"loanId": 123,
|
|
||||||
"loanCode": 1007
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 400: `{ "message": "Items array is required" | "No valid item IDs provided" | "Invalid date range" | ... }`
|
|
||||||
- 409: `{ "message": "Items not available in the selected period" }`
|
|
||||||
- 500: `{ "message": "Failed to create loan" }`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s -X POST http://localhost:8002/api/createLoan \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"items":[1,2],"startDate":"2025-08-01T09:00:00Z","endDate":"2025-08-02T09:00:00Z"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Admin API — /apiV2
|
### 4) Set return date (now) by loan code
|
||||||
|
|
||||||
These endpoints are protected by a static admin key in the path. Set `ADMIN_ID` in environment. No JWT required.
|
POST `/apiV2/setReturnDate/:key/:loan_code`
|
||||||
|
|
||||||
### GET /apiV2/items/:key
|
Sets the `returned_date` to the current server time.
|
||||||
|
|
||||||
Returns all items (no role filtering).
|
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||||
|
|
||||||
Implements: [`getItemsFromDatabaseV2`](backend/services/database.js)
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
|
|
||||||
- 200: `Item[]`
|
Example request:
|
||||||
- 403: `{ "message": "Access denied" }`
|
|
||||||
|
|
||||||
Example:
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
|
||||||
```sh
|
|
||||||
curl -s http://localhost:8002/apiV2/items/$ADMIN_ID
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /apiV2/controlInSafe/:key/:itemId/:state
|
Example response:
|
||||||
|
|
||||||
Updates `inSafe` state (0 or 1) for an item by ID.
|
```
|
||||||
|
{ "data": { /* update result */ } }
|
||||||
Implements: [`changeInSafeStateV2`](backend/services/database.js)
|
|
||||||
|
|
||||||
- `state`: `"0"` or `"1"`
|
|
||||||
- 200: `{ "message": "Item state updated successfully" }`
|
|
||||||
- 400: `{ "message": "Invalid state value" }`
|
|
||||||
- 403: `{ "message": "Access denied" }`
|
|
||||||
- 500: `{ "message": "Failed to update item state" }`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s -X POST http://localhost:8002/apiV2/controlInSafe/$ADMIN_ID/5/0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
## Error handling summary
|
|
||||||
|
|
||||||
- 400 Bad Request: invalid payloads or missing fields
|
|
||||||
- 401 Unauthorized: missing/invalid JWT (for `/api` routes)
|
|
||||||
- 403 Forbidden: wrong admin key (for `/apiV2` routes)
|
|
||||||
- 409 Conflict: loan overlaps with selected period
|
|
||||||
- 500 Internal Server Error: unexpected server/database errors
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running locally
|
### 5) Set take date (now) by loan code
|
||||||
|
|
||||||
With Docker Compose: [docker-compose.yml](docker-compose.yml)
|
POST `/apiV2/setTakeDate/:key/:loan_code`
|
||||||
|
|
||||||
- Backend: http://localhost:8002 (mounted from [backend](backend))
|
Sets the `take_date` to the current server time.
|
||||||
- MySQL: root password from `.env` as `DB_PASSWORD`, port 3309 on host
|
|
||||||
- Seed schema/data: import [backend/scheme.sql](backend/scheme.sql) into the DB
|
|
||||||
|
|
||||||
Environment required by backend:
|
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||||
|
|
||||||
- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
- `SECRET_KEY`
|
|
||||||
- `ADMIN_ID`
|
Example request:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "data": { /* update result */ } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
References:
|
## Error handling
|
||||||
|
|
||||||
- App routes: [backend/routes/api.js](backend/routes/api.js)
|
- 401 Unauthorized: Missing or invalid API key
|
||||||
- [`loginFunc`](backend/services/database.js), [`getItemsFromDatabase`](backend/services/database.js), [`getLoansFromDatabase`](backend/services/database.js), [`getUserLoansFromDatabase`](backend/services/database.js), [`deleteLoanFromDatabase`](backend/services/database.js), [`getBorrowableItemsFromDatabase`](backend/services/database.js), [`createLoanInDatabase`](backend/services/database.js)
|
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
|
||||||
- Admin routes: [backend/routes/apiV2.js](backend/routes/apiV2.js)
|
- 404 Not Found: Loan not found
|
||||||
- Auth: [`authenticate`](backend/services/tokenService.js),
|
- 500 Internal Server Error: Database or server error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you have questions or want to collaborate, please reach out!
|
||||||
|
BIN
Mock/Frames.pdf
BIN
Mock/Frames.pdf
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
# Borrow System
|
||||||
|
|
||||||
|
**You have reached the `debian12` branch.**
|
||||||
|
|
||||||
|
Here you will find the source code of exactly the application that I have hosted.
|
||||||
|
|
||||||
|
The main branch or the branch that I am developing on, is the `dev` branch.
|
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?
|
12
admin/Dockerfile
Normal file
12
admin/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8003
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
69
admin/README.md
Normal file
69
admin/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 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/) 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
|
||||||
|
|
||||||
|
## 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 tseslint.config([
|
||||||
|
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 tseslint.config([
|
||||||
|
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
admin/eslint.config.js
Normal file
23
admin/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 { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
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>Admin panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
6048
admin/package-lock.json
generated
Normal file
6048
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
admin/package.json
Normal file
49
admin/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"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.26.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tanstack/react-query": "^5.85.5",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
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;
|
70
admin/src/Layout/Layout.tsx
Normal file
70
admin/src/Layout/Layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Dashboard from "./Dashboard";
|
||||||
|
import Login from "./Login";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import Landingpage from "@/components/API/Landingpage";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
const Layout: React.FC = () => {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [showAPI, setShowAPI] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname.replace(/\/+$/, ""); // remove trailing slash
|
||||||
|
if (path === "/api") {
|
||||||
|
setShowAPI(true);
|
||||||
|
console.log("signal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Cookies.get("token")) {
|
||||||
|
const verifyToken = async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showAPI) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Landingpage />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
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;
|
82
admin/src/Layout/Sidebar.tsx
Normal file
82
admin/src/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
viewAusleihen: () => void;
|
||||||
|
viewGegenstaende: () => void;
|
||||||
|
viewSchliessfaecher: () => void;
|
||||||
|
viewUser: () => void;
|
||||||
|
viewAPI: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
viewAusleihen,
|
||||||
|
viewGegenstaende,
|
||||||
|
viewUser,
|
||||||
|
viewAPI,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="aside"
|
||||||
|
w="260px"
|
||||||
|
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>© Made with ❤️ by Theis Gaedigk</Text>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
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 |
238
admin/src/components/API/Landingpage.tsx
Normal file
238
admin/src/components/API/Landingpage.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Table,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Card,
|
||||||
|
SimpleGrid,
|
||||||
|
Button,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Lock, LockOpen } from "lucide-react";
|
||||||
|
import MyAlert from "../myChakra/MyAlert";
|
||||||
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
can_borrow_role: string;
|
||||||
|
inSafe: number;
|
||||||
|
entry_created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Landingpage: React.FC = () => {
|
||||||
|
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}/apiV2/allLoans`);
|
||||||
|
const loanData = await loanRes.json();
|
||||||
|
if (Array.isArray(loanData)) {
|
||||||
|
setLoans(loanData);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
"error",
|
||||||
|
"Fehler beim Laden",
|
||||||
|
"Unerwartetes Datenformat erhalten. (Ausleihen)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
|
||||||
|
const deviceData = await deviceRes.json();
|
||||||
|
if (Array.isArray(deviceData)) {
|
||||||
|
setDevices(deviceData);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
"error",
|
||||||
|
"Fehler beim Laden",
|
||||||
|
"Unerwartetes Datenformat erhalten. (Geräte)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
"error",
|
||||||
|
"Fehler beim Laden",
|
||||||
|
"Die Ausleihen konnten nicht geladen werden."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading as="h1" size="lg" mb={2}>
|
||||||
|
Matthias-Claudius-Schule Technik
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Heading as="h2" size="md" mb={4}>
|
||||||
|
Alle Ausleihen
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{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>Benutzername</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Startdatum</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Enddatum</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Ausgeliehene Artikel</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Rückgabedatum</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Ausleihdatum</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.returned_date)}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && loans.length === 0 && !isError && (
|
||||||
|
<Text color="gray.500" mt={2}>
|
||||||
|
Keine Ausleihen vorhanden.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Heading as="h2" size="md" mb={4}>
|
||||||
|
Alle Geräte
|
||||||
|
</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.inSafe ? "green" : "red"}
|
||||||
|
h="full"
|
||||||
|
minH="100px"
|
||||||
|
>
|
||||||
|
<Card.Header>
|
||||||
|
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />}
|
||||||
|
<Heading size="md">{device.item_name}</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body color="fg.muted">
|
||||||
|
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
|
||||||
|
<Text fontWeight="medium" color="fg.muted">
|
||||||
|
Legende:
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
colorPalette="green"
|
||||||
|
pointerEvents="none"
|
||||||
|
cursor="default"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<LockOpen size={16} />
|
||||||
|
<Text>Im Schließfach</Text>
|
||||||
|
</HStack>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
colorPalette="red"
|
||||||
|
pointerEvents="none"
|
||||||
|
cursor="default"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Lock size={16} />
|
||||||
|
<Text>Nicht im Schließfach</Text>
|
||||||
|
</HStack>
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Landingpage;
|
208
admin/src/components/APIKeyTable.tsx
Normal file
208
admin/src/components/APIKeyTable.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
type Items = {
|
||||||
|
id: number;
|
||||||
|
apiKey: string;
|
||||||
|
user: string;
|
||||||
|
entry_created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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/apiKeys`, {
|
||||||
|
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="md">
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{addAPIForm && (
|
||||||
|
<AddAPIKey
|
||||||
|
onClose={() => {
|
||||||
|
setAddAPIForm(false);
|
||||||
|
setReload(!reload);
|
||||||
|
}}
|
||||||
|
alert={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table.Root size="sm" striped>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>#</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>API Key</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Benutzer</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Eintrag erstellt am</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Aktionen</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{items.map((apiKey) => (
|
||||||
|
<Table.Row key={apiKey.id}>
|
||||||
|
<Table.Cell>{apiKey.id}</Table.Cell>
|
||||||
|
<Table.Cell>{apiKey.apiKey}</Table.Cell>
|
||||||
|
<Table.Cell>{apiKey.user}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
deleteAPKey(apiKey.id).then((response) => {
|
||||||
|
if (response.success) {
|
||||||
|
setItems(items.filter((i) => i.id !== apiKey.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;
|
81
admin/src/components/AddAPIKey.tsx
Normal file
81
admin/src/components/AddAPIKey.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||||
|
import { createAPIentry } from "@/utils/userActions";
|
||||||
|
|
||||||
|
type AddAPIKeyProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
alert: (
|
||||||
|
status: "success" | "error",
|
||||||
|
message: string,
|
||||||
|
description: string
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ 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 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">
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>API key</Field.Label>
|
||||||
|
<Input type="number" id="apiKey" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Benutzer</Field.Label>
|
||||||
|
<Input id="user" 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 user =
|
||||||
|
(
|
||||||
|
document.getElementById("user") as HTMLInputElement
|
||||||
|
)?.value.trim() || "";
|
||||||
|
|
||||||
|
if (!apiKey || !user) return;
|
||||||
|
|
||||||
|
const res = await createAPIentry(apiKey, user);
|
||||||
|
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;
|
86
admin/src/components/AddForm.tsx
Normal file
86
admin/src/components/AddForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, Card, Field, Input, Stack } 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 }) => {
|
||||||
|
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 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>Username</Field.Label>
|
||||||
|
<Input id="username" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Password</Field.Label>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Role</Field.Label>
|
||||||
|
<Input id="role" type="number" />
|
||||||
|
</Field.Root>
|
||||||
|
</Stack>
|
||||||
|
</Card.Body>
|
||||||
|
<Card.Footer justifyContent="flex-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={async () => {
|
||||||
|
const username =
|
||||||
|
(
|
||||||
|
document.getElementById("username") as HTMLInputElement
|
||||||
|
)?.value.trim() || "";
|
||||||
|
const password =
|
||||||
|
(document.getElementById("password") as HTMLInputElement)
|
||||||
|
?.value || "";
|
||||||
|
const role = Number(
|
||||||
|
(document.getElementById("role") as HTMLInputElement)?.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!username || !password || Number.isNaN(role)) return;
|
||||||
|
|
||||||
|
const res = await createUser(username, role, password);
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddForm;
|
86
admin/src/components/AddItemForm.tsx
Normal file
86
admin/src/components/AddItemForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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</Field.Label>
|
||||||
|
<Input id="item_name" placeholder="z.B. Laptop" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="can_borrow_role"
|
||||||
|
type="number"
|
||||||
|
placeholder="Zahl (1 - 4)"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!name || Number.isNaN(role)) return;
|
||||||
|
|
||||||
|
const res = await createItem(name, role);
|
||||||
|
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;
|
312
admin/src/components/ItemTable.tsx
Normal file
312
admin/src/components/ItemTable.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
type Items = {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
can_borrow_role: string;
|
||||||
|
inSafe: boolean;
|
||||||
|
entry_created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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/allItems`, {
|
||||||
|
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="md">
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table.Root size="sm" striped>
|
||||||
|
<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>
|
||||||
|
<strong>Eintrag erstellt am</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<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
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemNameChange(item.id, e.target.value)
|
||||||
|
}
|
||||||
|
value={item.item_name}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Input
|
||||||
|
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.inSafe ? "green.600" : "red.600"}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={item.inSafe ? "green.300" : "red.300"}
|
||||||
|
_hover={{
|
||||||
|
bg: item.inSafe ? "green.50" : "red.50",
|
||||||
|
borderColor: item.inSafe ? "green.400" : "red.400",
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
shadow: "sm",
|
||||||
|
}}
|
||||||
|
_active={{ transform: "translateY(0)" }}
|
||||||
|
aria-label={
|
||||||
|
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={item.inSafe ? CheckCircle2 : XCircle}
|
||||||
|
boxSize={3.5}
|
||||||
|
mr={2}
|
||||||
|
/>
|
||||||
|
<Text as="span" fontSize="xs" fontWeight="semibold">
|
||||||
|
{item.inSafe ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleEditItems(
|
||||||
|
item.id,
|
||||||
|
item.item_name,
|
||||||
|
item.can_borrow_role
|
||||||
|
).then((response) => {
|
||||||
|
if (response.success) {
|
||||||
|
setError(
|
||||||
|
"success",
|
||||||
|
"Gegenstand erfolgreich bearbeitet!",
|
||||||
|
"Gegenstand " +
|
||||||
|
'"' +
|
||||||
|
item.item_name +
|
||||||
|
'" mit ID ' +
|
||||||
|
item.id +
|
||||||
|
" bearbeitet."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
colorPalette="teal"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
deleteItem(item.id).then((response) => {
|
||||||
|
if (response.success) {
|
||||||
|
setItems(items.filter((i) => i.id !== item.id));
|
||||||
|
setError(
|
||||||
|
"success",
|
||||||
|
"Gegenstand gelöscht",
|
||||||
|
"Der Gegenstand wurde erfolgreich gelöscht."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
colorPalette="red"
|
||||||
|
size="sm"
|
||||||
|
ml={2}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemTable;
|
208
admin/src/components/LoanTable.tsx
Normal file
208
admin/src/components/LoanTable.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
const LoanTable: React.FC = () => {
|
||||||
|
const [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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/allLoans`, {
|
||||||
|
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="md">
|
||||||
|
Ausleihen
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{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>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>{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>
|
||||||
|
<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;
|
284
admin/src/components/UserTable.tsx
Normal file
284
admin/src/components/UserTable.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Heading,
|
||||||
|
} 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;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
entry_created_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: string) => {
|
||||||
|
setUsers((prevUsers) =>
|
||||||
|
prevUsers.map((user) =>
|
||||||
|
user.id === userId ? { ...user, [field]: value } : user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (username: string) => {
|
||||||
|
setChangeUsr(username);
|
||||||
|
setChangePWform(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchUserData();
|
||||||
|
console.log("user api response", data);
|
||||||
|
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="md">
|
||||||
|
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>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>#</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Benutzername</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Passwort ändern</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Rolle</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Eintrag erstellt am</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>
|
||||||
|
<strong>Aktionen</strong>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{users.map((user) => (
|
||||||
|
<Table.Row key={user.id}>
|
||||||
|
<Table.Cell>{user.id}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Input
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange(user.id, "username", e.target.value)
|
||||||
|
}
|
||||||
|
value={user.username}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button onClick={() => handlePasswordChange(user.username)}>
|
||||||
|
Passwort ändern
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange(user.id, "role", e.target.value)
|
||||||
|
}
|
||||||
|
value={user.role}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleEdit(
|
||||||
|
user.id,
|
||||||
|
user.username,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
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>
|
||||||
|
);
|
16
admin/src/utils/fetcher.ts
Normal file
16
admin/src/utils/fetcher.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
export const fetchUserData = async () => {
|
||||||
|
const response = await fetch(`${API_BASE}/api/allUsers`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
};
|
48
admin/src/utils/loginUser.ts
Normal file
48
admin/src/utils/loginUser.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
export type LoginSuccess = { success: true };
|
||||||
|
export type 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/loginAdmin`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
18
admin/src/utils/toastify.ts
Normal file
18
admin/src/utils/toastify.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { toast, Flip, type ToastOptions } from "react-toastify";
|
||||||
|
|
||||||
|
export type ToastType = "success" | "error" | "info" | "warning";
|
||||||
|
|
||||||
|
export const myToast = (message: string, msgType: ToastType) => {
|
||||||
|
let config: ToastOptions = {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: true,
|
||||||
|
progress: undefined,
|
||||||
|
theme: "colored",
|
||||||
|
transition: Flip,
|
||||||
|
};
|
||||||
|
toast[msgType](message, config);
|
||||||
|
};
|
257
admin/src/utils/userActions.ts
Normal file
257
admin/src/utils/userActions.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
export const handleDelete = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/deleteUser/${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,
|
||||||
|
username: string,
|
||||||
|
role: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/editUser/${userId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, role }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/createUser`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, role, password }),
|
||||||
|
});
|
||||||
|
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/changePWadmin`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ newPassword, username }),
|
||||||
|
});
|
||||||
|
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/deleteLoan/${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/deleteItem/${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
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/createItem`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ item_name, can_borrow_role }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf 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,
|
||||||
|
can_borrow_role: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/updateItemByID`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ itemId, item_name, 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/changeSafeState/${itemId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
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, user: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ apiKey, user }),
|
||||||
|
});
|
||||||
|
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/deleteAPKey/${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`;
|
||||||
|
};
|
1
admin/src/vite-env.d.ts
vendored
Normal file
1
admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
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: [],
|
||||||
|
};
|
36
admin/tsconfig.app.json
Normal file
36
admin/tsconfig.app.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"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,
|
||||||
|
|
||||||
|
/* Chakra / Pfad Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
},
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
25
admin/tsconfig.node.json
Normal file
25
admin/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"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"]
|
||||||
|
}
|
20
admin/vite.config.ts
Normal file
20
admin/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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",
|
||||||
|
allowedHosts: ["admin.insta.the1s.de"],
|
||||||
|
port: 8103,
|
||||||
|
watch: { usePolling: true },
|
||||||
|
hmr: {
|
||||||
|
host: "admin.insta.the1s.de",
|
||||||
|
port: 8103,
|
||||||
|
protocol: "wss",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@@ -7,6 +7,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8102
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@@ -14,7 +14,8 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
"mysql2": "^3.14.3"
|
"mysql2": "^3.14.3",
|
||||||
|
"nodemailer": "^7.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -713,6 +714,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
@@ -16,6 +16,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
"mysql2": "^3.14.3"
|
"mysql2": "^3.14.3",
|
||||||
|
"nodemailer": "^7.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,11 +7,210 @@ import {
|
|||||||
deleteLoanFromDatabase,
|
deleteLoanFromDatabase,
|
||||||
getBorrowableItemsFromDatabase,
|
getBorrowableItemsFromDatabase,
|
||||||
createLoanInDatabase,
|
createLoanInDatabase,
|
||||||
|
onTake,
|
||||||
|
loginAdmin,
|
||||||
|
onReturn,
|
||||||
|
getAllUsers,
|
||||||
|
deleteUserID,
|
||||||
|
handleEdit,
|
||||||
|
createUser,
|
||||||
|
getAllLoans,
|
||||||
|
getAllItems,
|
||||||
|
deleteItemID,
|
||||||
|
createItem,
|
||||||
|
changeUserPassword,
|
||||||
|
changeUserPasswordFRONTEND,
|
||||||
|
changeInSafeStateV2,
|
||||||
|
updateItemByID,
|
||||||
|
getAllApiKeys,
|
||||||
|
createAPIentry,
|
||||||
|
deleteAPKey,
|
||||||
|
getLoanInfoWithID,
|
||||||
} from "../services/database.js";
|
} from "../services/database.js";
|
||||||
import { authenticate, generateToken } from "../services/tokenService.js";
|
import { authenticate, generateToken } from "../services/tokenService.js";
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Nice HTML + text templates for the loan email
|
||||||
|
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||||
|
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||||
|
const itemsList =
|
||||||
|
Array.isArray(items) && items.length
|
||||||
|
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
|
||||||
|
.map(
|
||||||
|
(i) =>
|
||||||
|
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
|
||||||
|
)
|
||||||
|
.join("")}</ul>`
|
||||||
|
: "<span style='color:#111827;'>N/A</span>";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; supported-color-schemes: light; }
|
||||||
|
body { margin:0; padding:0; }
|
||||||
|
/* Mobile stacking */
|
||||||
|
@media (max-width:480px) {
|
||||||
|
.outer { width:100% !important; }
|
||||||
|
.pad-sm { padding:16px !important; }
|
||||||
|
.w-label { width:120px !important; }
|
||||||
|
}
|
||||||
|
/* Dark-mode override safety */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
|
||||||
|
.brand-header { background:${brand} !important; color:#ffffff !important; }
|
||||||
|
a { color:${brand} !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
|
||||||
|
<!-- Preheader (hidden) -->
|
||||||
|
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
|
||||||
|
Neue Ausleihe erstellt – Übersicht der Buchung.
|
||||||
|
</div>
|
||||||
|
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
|
||||||
|
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
|
||||||
|
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
|
||||||
|
user || "N/A"
|
||||||
|
}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
||||||
|
startDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
||||||
|
endDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
|
||||||
|
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
|
||||||
|
createdDate
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="margin:22px 0 0 0; font-size:14px;">
|
||||||
|
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
|
||||||
|
Übersicht öffnen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
|
||||||
|
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
||||||
|
const itemsText =
|
||||||
|
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
||||||
|
return [
|
||||||
|
"Neue Ausleihe erstellt",
|
||||||
|
"",
|
||||||
|
`Benutzer: ${user || "N/A"}`,
|
||||||
|
`Gegenstände: ${itemsText}`,
|
||||||
|
`Start: ${formatDateTime(startDate)}`,
|
||||||
|
`Ende: ${formatDateTime(endDate)}`,
|
||||||
|
`Erstellt am: ${formatDateTime(createdDate)}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.MAIL_HOST,
|
||||||
|
port: process.env.MAIL_PORT,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: process.env.MAIL_USER,
|
||||||
|
pass: process.env.MAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
||||||
|
to: process.env.MAIL_SENDEES,
|
||||||
|
subject: "Eine neue Ausleihe wurde erstellt!",
|
||||||
|
text: buildLoanEmailText({
|
||||||
|
user,
|
||||||
|
items,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
createdDate,
|
||||||
|
}),
|
||||||
|
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Message sent:", info.messageId);
|
||||||
|
})();
|
||||||
|
console.log("sendMailLoan called");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (value == null) return "N/A";
|
||||||
|
|
||||||
|
const toOut = (d) => {
|
||||||
|
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value instanceof Date) return toOut(value);
|
||||||
|
if (typeof value === "number") return toOut(new Date(value));
|
||||||
|
|
||||||
|
const s = String(value).trim();
|
||||||
|
|
||||||
|
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
|
||||||
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
|
||||||
|
if (m) {
|
||||||
|
const [, y, M, d, h, min] = m;
|
||||||
|
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO or other parseable formats
|
||||||
|
const dObj = new Date(s);
|
||||||
|
if (!isNaN(dObj.getTime())) return toOut(dObj);
|
||||||
|
|
||||||
|
return "N/A";
|
||||||
|
};
|
||||||
|
|
||||||
// Example endpoint
|
|
||||||
router.post("/login", async (req, res) => {
|
router.post("/login", async (req, res) => {
|
||||||
const result = await loginFunc(req.body.username, req.body.password);
|
const result = await loginFunc(req.body.username, req.body.password);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -26,7 +225,6 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get("/items", authenticate, async (req, res) => {
|
router.get("/items", authenticate, async (req, res) => {
|
||||||
console.log(req);
|
|
||||||
const result = await getItemsFromDatabase(req.user.role);
|
const result = await getItemsFromDatabase(req.user.role);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
res.status(200).json(result.data);
|
res.status(200).json(result.data);
|
||||||
@@ -86,6 +284,26 @@ router.post("/borrowableItems", authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/takeLoan/:id", authenticate, async (req, res) => {
|
||||||
|
const loanId = req.params.id;
|
||||||
|
const result = await onTake(loanId);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Loan taken successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to take loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/returnLoan/:id", authenticate, async (req, res) => {
|
||||||
|
const loanId = req.params.id;
|
||||||
|
const result = await onReturn(loanId);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Loan returned successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to return loan" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/createLoan", authenticate, async (req, res) => {
|
router.post("/createLoan", authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { items, startDate, endDate } = req.body || {};
|
const { items, startDate, endDate } = req.body || {};
|
||||||
@@ -121,6 +339,15 @@ router.post("/createLoan", authenticate, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||||
|
console.log(mailInfo);
|
||||||
|
sendMailLoan(
|
||||||
|
mailInfo.data.username,
|
||||||
|
mailInfo.data.loaned_items_name,
|
||||||
|
mailInfo.data.start_date,
|
||||||
|
mailInfo.data.end_date,
|
||||||
|
mailInfo.data.created_at
|
||||||
|
);
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Loan created successfully",
|
message: "Loan created successfully",
|
||||||
loanId: result.data.id,
|
loanId: result.data.id,
|
||||||
@@ -145,4 +372,217 @@ router.post("/createLoan", authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/changePassword", authenticate, async (req, res) => {
|
||||||
|
const { oldPassword, newPassword } = req.body || {};
|
||||||
|
const username = req.user.username;
|
||||||
|
const result = await changeUserPasswordFRONTEND(
|
||||||
|
username,
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ message: "Password changed successfully" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to change password" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin panel functions
|
||||||
|
|
||||||
|
router.post("/loginAdmin", async (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Username and password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await loginAdmin(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
const token = await generateToken({
|
||||||
|
username: result.data.username,
|
||||||
|
role: result.data.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Login successful",
|
||||||
|
first_name: result.data.first_name,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ message: "Invalid credentials" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allUsers", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllUsers();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch users" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const result = await deleteUserID(userId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "User deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/verifyToken", authenticate, async (req, res) => {
|
||||||
|
res.status(200).json({ message: "Token is valid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/editUser/:id", authenticate, async (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { username, role } = req.body || {};
|
||||||
|
const result = await handleEdit(userId, username, role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "User edited successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to edit user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createUser", authenticate, async (req, res) => {
|
||||||
|
const { username, role, password } = req.body || {};
|
||||||
|
const result = await createUser(username, role, password);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "User created successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allLoans", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllLoans();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch loans" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/allItems", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllItems();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch items" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
|
||||||
|
const itemId = req.params.id;
|
||||||
|
const result = await deleteItemID(itemId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Item deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createItem", authenticate, async (req, res) => {
|
||||||
|
const { item_name, can_borrow_role } = req.body || {};
|
||||||
|
const result = await createItem(item_name, can_borrow_role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "Item created successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/changePWadmin", authenticate, async (req, res) => {
|
||||||
|
const newPassword = req.body.newPassword;
|
||||||
|
if (!newPassword) {
|
||||||
|
return res.status(400).json({ message: "New password is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await changeUserPassword(req.body.username, newPassword);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Password changed successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to change password" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateItemByID", authenticate, async (req, res) => {
|
||||||
|
const role = req.body.can_borrow_role;
|
||||||
|
const itemId = req.body.itemId;
|
||||||
|
const item_name = req.body.item_name;
|
||||||
|
const result = await updateItemByID(itemId, item_name, role);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "Item updated successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to update item" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
|
||||||
|
const itemId = req.params.itemId;
|
||||||
|
const result = await changeInSafeStateV2(itemId);
|
||||||
|
if (result.success) {
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "Item safe state updated successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to update item safe state" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/apiKeys", authenticate, async (req, res) => {
|
||||||
|
const result = await getAllApiKeys();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch API keys" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
|
||||||
|
const apiKeyId = req.params.id;
|
||||||
|
const result = await deleteAPKey(apiKeyId);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json({ message: "API key deleted successfully" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to delete API key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createAPIentry", authenticate, async (req, res) => {
|
||||||
|
const apiKey = req.body.apiKey;
|
||||||
|
const user = req.body.user;
|
||||||
|
if (!apiKey || !user) {
|
||||||
|
return res.status(400).json({ message: "API key and user are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure apiKey is a number
|
||||||
|
const apiKeyNum = Number(apiKey);
|
||||||
|
if (!Number.isFinite(apiKeyNum)) {
|
||||||
|
return res.status(400).json({ message: "API key must be a number" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createAPIentry(apiKeyNum, user);
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(201).json({ message: "API key created successfully" });
|
||||||
|
}
|
||||||
|
if (result.code === "DUPLICATE") {
|
||||||
|
return res.status(409).json({ message: "API key already exists" });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to create API key" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/apiKeys/validate/:key", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawKey = req.params.key;
|
||||||
|
const result = await getAllApiKeys();
|
||||||
|
if (!result.success || !Array.isArray(result.data)) {
|
||||||
|
return res.status(500).json({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = result.data.some((entry) => {
|
||||||
|
const val = String(
|
||||||
|
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
|
||||||
|
);
|
||||||
|
return val === String(rawKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ valid: isValid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("validate api key error:", err);
|
||||||
|
return res.status(500).json({ valid: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@@ -3,40 +3,130 @@ import dotenv from "dotenv";
|
|||||||
import {
|
import {
|
||||||
getItemsFromDatabaseV2,
|
getItemsFromDatabaseV2,
|
||||||
changeInSafeStateV2,
|
changeInSafeStateV2,
|
||||||
|
setTakeDateV2,
|
||||||
|
setReturnDateV2,
|
||||||
|
getLoanByCodeV2,
|
||||||
|
getAllLoansV2,
|
||||||
|
getAPIkey,
|
||||||
} from "../services/database.js";
|
} from "../services/database.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get("/items/:key", async (req, res) => {
|
async function validateAPIKey(apiKey) {
|
||||||
if (req.params.key === process.env.ADMIN_ID) {
|
try {
|
||||||
const result = await getItemsFromDatabaseV2();
|
if (!apiKey) return false;
|
||||||
if (result.success) {
|
const result = await getAPIkey();
|
||||||
res.status(200).json(result.data);
|
if (!result?.success || !Array.isArray(result.data)) return false;
|
||||||
} else {
|
return result.data.some((row) => String(row.apiKey) === String(apiKey));
|
||||||
res.status(500).json({ message: "Failed to fetch items" });
|
} catch (err) {
|
||||||
|
console.error("validateAPIKey error:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a guard that returns Access Denied instead of hanging
|
||||||
|
const apiKeyGuard = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = req.params.key;
|
||||||
|
if (!key) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ message: "Access denied: missing API key" });
|
||||||
}
|
}
|
||||||
|
const ok = await validateAPIKey(key);
|
||||||
|
if (!ok) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ message: "Access denied: invalid API key" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("apiKeyGuard error:", e);
|
||||||
|
res.status(500).json({ message: "Internal server error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route for API to get ALL items from the database
|
||||||
|
router.get("/items/:key", apiKeyGuard, async (req, res) => {
|
||||||
|
const result = await getItemsFromDatabaseV2();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
} else {
|
} else {
|
||||||
res.status(403).json({ message: "Access denied" });
|
res.status(500).json({ message: "Failed to fetch items" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
|
// Route for API to control the position of an item
|
||||||
if (req.params.key === process.env.ADMIN_ID) {
|
router.post(
|
||||||
|
"/controlInSafe/:key/:itemId/:state",
|
||||||
|
apiKeyGuard,
|
||||||
|
async (req, res) => {
|
||||||
const itemId = req.params.itemId;
|
const itemId = req.params.itemId;
|
||||||
const state = req.params.state;
|
const state = req.params.state;
|
||||||
|
|
||||||
if (state === "1" || state === "0") {
|
if (state === "1" || state === "0") {
|
||||||
const result = await changeInSafeStateV2(itemId, state);
|
const result = await changeInSafeStateV2(itemId, state);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
res.status(200).json({ message: "Item state updated successfully" });
|
res.status(200).json({ data: result.data });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ message: "Failed to update item state" });
|
res.status(500).json({ message: "Failed to update item state" });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ message: "Invalid state value" });
|
res.status(400).json({ message: "Invalid state value" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route for API to get a loan by its code
|
||||||
|
router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loan_code = req.params.loan_code;
|
||||||
|
const result = await getLoanByCodeV2(loan_code);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
} else {
|
} else {
|
||||||
res.status(403).json({ message: "Access denied" });
|
res.status(404).json({ message: "Loan not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to set the return date by the loan code
|
||||||
|
router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loanCode = req.params.loan_code;
|
||||||
|
const result = await setReturnDateV2(loanCode);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to set return date" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to set the take away date by the loan code
|
||||||
|
router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
|
||||||
|
const loanCode = req.params.loan_code;
|
||||||
|
const result = await setTakeDateV2(loanCode);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ data: result.data });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to set take date" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
|
||||||
|
router.get("/allLoans", async (req, res) => {
|
||||||
|
const result = await getAllLoansV2();
|
||||||
|
if (result.success) {
|
||||||
|
return res.status(200).json(result.data);
|
||||||
|
}
|
||||||
|
return res.status(500).json({ message: "Failed to fetch loans" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for API to get ALL items from the database (only for landingpage)
|
||||||
|
router.get("/allItems", async (req, res) => {
|
||||||
|
const result = await getItemsFromDatabaseV2();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch items" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -7,6 +7,18 @@ CREATE TABLE `users` (
|
|||||||
`username` varchar(100) NOT NULL,
|
`username` varchar(100) NOT NULL,
|
||||||
`password` varchar(255) NOT NULL,
|
`password` varchar(255) NOT NULL,
|
||||||
`role` int DEFAULT NULL,
|
`role` int DEFAULT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `admins` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`first_name` varchar(255) NOT NULL,
|
||||||
|
`last_name` varchar(255) NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `username` (`username`)
|
UNIQUE KEY `username` (`username`)
|
||||||
);
|
);
|
||||||
@@ -17,6 +29,7 @@ CREATE TABLE `loans` (
|
|||||||
`loan_code` int NOT NULL,
|
`loan_code` int NOT NULL,
|
||||||
`start_date` timestamp NOT NULL,
|
`start_date` timestamp NOT NULL,
|
||||||
`end_date` timestamp NOT NULL,
|
`end_date` timestamp NOT NULL,
|
||||||
|
`take_date` timestamp NULL DEFAULT NULL,
|
||||||
`returned_date` timestamp NULL DEFAULT NULL,
|
`returned_date` timestamp NULL DEFAULT NULL,
|
||||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
|
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
|
||||||
@@ -30,6 +43,7 @@ CREATE TABLE `items` (
|
|||||||
`item_name` varchar(255) NOT NULL,
|
`item_name` varchar(255) NOT NULL,
|
||||||
`can_borrow_role` INT NOT NULL,
|
`can_borrow_role` INT NOT NULL,
|
||||||
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
|
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `item_name` (`item_name`)
|
UNIQUE KEY `item_name` (`item_name`)
|
||||||
);
|
);
|
||||||
@@ -38,100 +52,48 @@ CREATE TABLE `lockers` (
|
|||||||
`id` int NOT NULL AUTO_INCREMENT,
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
`item` varchar(255) NOT NULL,
|
`item` varchar(255) NOT NULL,
|
||||||
`locker_number` int NOT NULL,
|
`locker_number` int NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `item` (`item`),
|
UNIQUE KEY `item` (`item`),
|
||||||
UNIQUE KEY `locker_number` (`locker_number`)
|
UNIQUE KEY `locker_number` (`locker_number`)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Mock data for users
|
CREATE TABLE `apiKeys` (
|
||||||
INSERT INTO `users` (`username`, `password`, `role`) VALUES
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
('alice', 'password1', 1),
|
`apiKey` int NOT NULL UNIQUE,
|
||||||
('bob', 'password2', 2),
|
`user` VARCHAR(255) NOT NULL,
|
||||||
('carol', 'password3', 1),
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
('dave', 'password4', 3),
|
PRIMARY KEY (`id`)
|
||||||
('eve', 'password5', 2),
|
);
|
||||||
('frank', 'password6', 1),
|
|
||||||
('grace', 'password7', 2),
|
|
||||||
('heidi', 'password8', 3),
|
|
||||||
('ivan', 'password9', 1),
|
|
||||||
('judy', 'password10', 2),
|
|
||||||
('mallory', 'password11', 1),
|
|
||||||
('oscar', 'password12', 3),
|
|
||||||
('peggy', 'password13', 2),
|
|
||||||
('trent', 'password14', 1),
|
|
||||||
('victor', 'password15', 2),
|
|
||||||
('wendy', 'password16', 3),
|
|
||||||
('zoe', 'password17', 1),
|
|
||||||
('quinn', 'password18', 2),
|
|
||||||
('ruth', 'password19', 1),
|
|
||||||
('sam', 'password20', 3);
|
|
||||||
|
|
||||||
-- Mock data for loans
|
|
||||||
INSERT INTO `loans` (`username`, `loan_code`, `start_date`, `end_date`, `returned_date`, `loaned_items_id`, `loaned_items_name`)
|
|
||||||
VALUES
|
|
||||||
('alice', 1001, '2025-08-01 09:00:00', '2025-08-10 09:00:00', NULL, '[1,2]', '["Laptop","Projector"]'),
|
|
||||||
('bob', 1002, '2025-08-02 10:00:00', '2025-08-12 10:00:00', NULL, '[3]', '["Tablet"]'),
|
|
||||||
('carol', 1003, '2025-08-03 11:00:00', '2025-08-13 11:00:00', NULL, '[4,5]', '["Camera","Tripod"]'),
|
|
||||||
('dave', 1004, '2025-08-04 12:00:00', '2025-08-14 12:00:00', NULL, '[6]', '["Microphone"]'),
|
|
||||||
('eve', 1005, '2025-08-05 13:00:00', '2025-08-15 13:00:00', NULL, '[7,8]', '["Speaker","Monitor"]'),
|
|
||||||
('frank', 1006, '2025-08-06 14:00:00', '2025-08-16 14:00:00', NULL, '[9]', '["Keyboard"]'),
|
|
||||||
('grace', 1007, '2025-08-07 15:00:00', '2025-08-17 15:00:00', NULL, '[10,11]', '["Mouse","Printer"]'),
|
|
||||||
('heidi', 1008, '2025-08-08 16:00:00', '2025-08-18 16:00:00', NULL, '[12]', '["Scanner"]'),
|
|
||||||
('ivan', 1009, '2025-08-09 17:00:00', '2025-08-19 17:00:00', NULL, '[13,14]', '["Router","Switch"]'),
|
|
||||||
('judy', 1010, '2025-08-10 18:00:00', '2025-08-20 18:00:00', NULL, '[15]', '["Projector"]'),
|
|
||||||
('mallory', 1011, '2025-08-11 09:00:00', '2025-08-21 09:00:00', NULL, '[16,17]', '["Laptop","Tablet"]'),
|
|
||||||
('oscar', 1012, '2025-08-12 10:00:00', '2025-08-22 10:00:00', NULL, '[18]', '["Camera"]'),
|
|
||||||
('peggy', 1013, '2025-08-13 11:00:00', '2025-08-23 11:00:00', NULL, '[19,20]', '["Tripod","Microphone"]'),
|
|
||||||
('trent', 1014, '2025-08-14 12:00:00', '2025-08-24 12:00:00', NULL, '[1]', '["Laptop"]'),
|
|
||||||
('victor', 1015, '2025-08-15 13:00:00', '2025-08-25 13:00:00', NULL, '[2,3]', '["Projector","Tablet"]'),
|
|
||||||
('wendy', 1016, '2025-08-16 14:00:00', '2025-08-26 14:00:00', NULL, '[4]', '["Camera"]'),
|
|
||||||
('zoe', 1017, '2025-08-17 15:00:00', '2025-08-27 15:00:00', NULL, '[5,6]', '["Tripod","Microphone"]'),
|
|
||||||
('quinn', 1018, '2025-08-18 16:00:00', '2025-08-28 16:00:00', NULL, '[7]', '["Speaker"]'),
|
|
||||||
('ruth', 1019, '2025-08-19 17:00:00', '2025-08-29 17:00:00', NULL, '[8,9]', '["Monitor","Keyboard"]'),
|
|
||||||
('sam', 1020, '2025-08-20 18:00:00', '2025-08-30 18:00:00', NULL, '[10]', '["Mouse"]');
|
|
||||||
|
|
||||||
-- Mock data for items
|
|
||||||
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
|
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
|
||||||
('Laptop', 1, 1),
|
('DJI 1er Mikro', 4, 1),
|
||||||
('Projector', 2, 1),
|
('DJI 2er Mikro 1', 4, 1),
|
||||||
('Tablet', 1, 1),
|
('DJI 2er Mikro 2', 4, 1),
|
||||||
('Camera', 2, 1),
|
('Rode Richt Mikrofon', 2, 1),
|
||||||
('Tripod', 1, 1),
|
('Kamera Stativ', 1, 0),
|
||||||
('Microphone', 3, 1),
|
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
|
||||||
('Speaker', 2, 1),
|
('MacBook inkl. Adapter', 2, 0),
|
||||||
('Monitor', 1, 1),
|
('SD Karten', 3, 0),
|
||||||
('Keyboard', 2, 1),
|
('Kameragimbal', 1, 0),
|
||||||
('Mouse', 1, 1),
|
('ATEM MINI PRO', 1, 1),
|
||||||
('Printer', 3, 1),
|
('Handygimbal', 4, 0),
|
||||||
('Scanner', 2, 1),
|
('Kameralüfter', 1, 1),
|
||||||
('Router', 1, 1),
|
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
|
||||||
('Switch', 2, 1),
|
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
|
||||||
('Charger', 1, 1),
|
|
||||||
('USB Cable', 2, 1),
|
|
||||||
('HDMI Cable', 1, 1),
|
|
||||||
('Webcam', 3, 1),
|
|
||||||
('Headphones', 2, 1),
|
|
||||||
('Smartphone', 1, 1);
|
|
||||||
|
|
||||||
-- Mock data for lockers
|
|
||||||
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
|
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
|
||||||
('Laptop', 101),
|
('DJI 1er Mikro', 1),
|
||||||
('Projector', 102),
|
('DJI 2er Mikro 1', 2),
|
||||||
('Tablet', 103),
|
('DJI 2er Mikro 2', 3),
|
||||||
('Camera', 104),
|
('Rode Richt Mikrofon', 4),
|
||||||
('Tripod', 105),
|
('Kamera Stativ', 5),
|
||||||
('Microphone', 106),
|
('SONY Kamera - inkl. Akkus und Objektiv', 6),
|
||||||
('Speaker', 107),
|
('MacBook inkl. Adapter', 7),
|
||||||
('Monitor', 108),
|
('SD Karten', 8),
|
||||||
('Keyboard', 109),
|
('Kameragimbal', 9),
|
||||||
('Mouse', 110),
|
('ATEM MINI PRO', 10),
|
||||||
('Printer', 111),
|
('Handygimbal', 11),
|
||||||
('Scanner', 112),
|
('Kameralüfter', 12),
|
||||||
('Router', 113),
|
('Kleine Kamera 1 - inkl. Objektiv', 13),
|
||||||
('Switch', 114),
|
('Kleine Kamera 2 - inkl. Objektiv', 14);
|
||||||
('Charger', 115),
|
|
||||||
('USB Cable', 116),
|
|
||||||
('HDMI Cable', 117),
|
|
||||||
('Webcam', 118),
|
|
||||||
('Headphones', 119),
|
|
||||||
('Smartphone', 120);
|
|
@@ -5,7 +5,7 @@ import apiRouter from "./routes/api.js";
|
|||||||
import apiRouterV2 from "./routes/apiV2.js";
|
import apiRouterV2 from "./routes/apiV2.js";
|
||||||
env.config();
|
env.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 8002;
|
const port = 8102;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
// Increase body size limits to support large CSV JSON payloads
|
// Increase body size limits to support large CSV JSON payloads
|
||||||
|
@@ -8,6 +8,7 @@ const pool = mysql
|
|||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
})
|
})
|
||||||
.promise();
|
.promise();
|
||||||
|
|
||||||
@@ -28,10 +29,21 @@ export const getItemsFromDatabaseV2 = async () => {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const changeInSafeStateV2 = async (itemId, state) => {
|
export const getLoanByCodeV2 = async (loan_code) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"UPDATE items SET inSafe = ? WHERE id = ?",
|
"SELECT * FROM loans WHERE loan_code = ?;",
|
||||||
[state, itemId]
|
[loan_code]
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
return { success: true, data: result[0] };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeInSafeStateV2 = async (itemId) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
|
||||||
|
[itemId]
|
||||||
);
|
);
|
||||||
if (result.affectedRows > 0) {
|
if (result.affectedRows > 0) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -39,6 +51,62 @@ export const changeInSafeStateV2 = async (itemId, state) => {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setReturnDateV2 = async (loanCode) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setTakeDateV2 = async (loanCode) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
||||||
|
[loanCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
export const getItemsFromDatabase = async (role) => {
|
export const getItemsFromDatabase = async (role) => {
|
||||||
const sql =
|
const sql =
|
||||||
role == 0
|
role == 0
|
||||||
@@ -54,11 +122,8 @@ export const getItemsFromDatabase = async (role) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getLoansFromDatabase = async () => {
|
export const getLoansFromDatabase = async () => {
|
||||||
const [result] = await pool.query("SELECT * FROM loans;");
|
const [rows] = await pool.query("SELECT * FROM loans;");
|
||||||
if (result.length > 0) {
|
return { success: true, data: rows.length > 0 ? rows : null };
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserLoansFromDatabase = async (username) => {
|
export const getUserLoansFromDatabase = async (username) => {
|
||||||
@@ -118,6 +183,16 @@ export const getBorrowableItemsFromDatabase = async (
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLoanInfoWithID = async (loanId) => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
|
||||||
|
loanId,
|
||||||
|
]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows[0] };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
export const createLoanInDatabase = async (
|
export const createLoanInDatabase = async (
|
||||||
username,
|
username,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -199,7 +274,7 @@ export const createLoanInDatabase = async (
|
|||||||
// Generate unique loan_code (retry a few times)
|
// Generate unique loan_code (retry a few times)
|
||||||
let loanCode = null;
|
let loanCode = null;
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const candidate = Math.floor(1000 + Math.random() * 900000); // 4-6 digits
|
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
|
||||||
const [exists] = await conn.query(
|
const [exists] = await conn.query(
|
||||||
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
||||||
[candidate]
|
[candidate]
|
||||||
@@ -260,3 +335,203 @@ export const createLoanInDatabase = async (
|
|||||||
conn.release();
|
conn.release();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These functions are only temporary, and will be deleted when the full bin is set up.
|
||||||
|
|
||||||
|
export const onTake = async (loanId) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET take_date = NOW() WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onReturn = async (loanId) => {
|
||||||
|
const [items] = await pool.query(
|
||||||
|
"SELECT loaned_items_id FROM loans WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return { success: false };
|
||||||
|
|
||||||
|
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||||
|
? items[0].loaned_items_id
|
||||||
|
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||||
|
|
||||||
|
const [setItemStates] = await pool.query(
|
||||||
|
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
|
||||||
|
[itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
|
||||||
|
[loanId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginAdmin = async (username, password) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT * FROM admins WHERE username = ? AND password = ?",
|
||||||
|
[username, password]
|
||||||
|
);
|
||||||
|
if (result.length > 0) return { success: true, data: result[0] };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsers = async () => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"SELECT id, username, role, entry_created_at FROM users"
|
||||||
|
);
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserID = async (userId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEdit = async (userId, username, role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET username = ?, role = ? WHERE id = ?",
|
||||||
|
[username, role, userId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (username, role, password) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
|
||||||
|
[username, role, password]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoans = async () => {
|
||||||
|
const [result] = await pool.query("SELECT * FROM loans");
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllItems = async () => {
|
||||||
|
const [result] = await pool.query("SELECT * FROM items");
|
||||||
|
if (result.length > 0) return { success: true, data: result };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItemID = async (itemId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createItem = async (item_name, can_borrow_role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
|
||||||
|
[item_name, can_borrow_role]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeUserPassword = async (username, newPassword) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET password = ? WHERE username = ?",
|
||||||
|
[newPassword, username]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeUserPasswordFRONTEND = async (
|
||||||
|
username,
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE users SET password = ? WHERE username = ? AND password = ?",
|
||||||
|
[newPassword, username, oldPassword]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE items SET item_name = ?, can_borrow_role = ? WHERE id = ?",
|
||||||
|
[item_name, can_borrow_role, itemId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoansV2 = async () => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, username, start_date, end_date, loaned_items_name, returned_date, take_date FROM loans"
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllApiKeys = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT * FROM apiKeys");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAPIentry = async (apiKey, user) => {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
|
||||||
|
[apiKey, user]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAPKey = async (apiKeyId) => {
|
||||||
|
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
|
||||||
|
apiKeyId,
|
||||||
|
]);
|
||||||
|
if (result.affectedRows > 0) return { success: true };
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAPIkey = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
|
@@ -9,7 +9,6 @@ export async function generateToken(payload) {
|
|||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime("2h") // Token valid for 2 hours
|
.setExpirationTime("2h") // Token valid for 2 hours
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
console.log("Generated token: ", newToken);
|
|
||||||
return newToken;
|
return newToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,23 +1,45 @@
|
|||||||
services:
|
services:
|
||||||
# borrow_system-frontend:
|
borrow_system-frontend:
|
||||||
# container_name: borrow_system-frontend
|
container_name: borrow_system-frontend
|
||||||
# build: ./frontend
|
build: ./frontend
|
||||||
# ports:
|
ports:
|
||||||
# - "8001:8001"
|
- "8101:8101"
|
||||||
# environment:
|
networks:
|
||||||
# - CHOKIDAR_USEPOLLING=true
|
- proxynet
|
||||||
# volumes:
|
- borrow_system-internal
|
||||||
# - ./frontend:/app
|
environment:
|
||||||
# - /app/node_modules
|
- CHOKIDAR_USEPOLLING=true
|
||||||
# restart: unless-stopped
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
admin-frontend:
|
||||||
|
container_name: admin-frontend
|
||||||
|
build: ./admin
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
- borrow_system-internal
|
||||||
|
ports:
|
||||||
|
- "8103:8103"
|
||||||
|
environment:
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
volumes:
|
||||||
|
- ./admin:/app
|
||||||
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
borrow_system-backend:
|
borrow_system-backend:
|
||||||
container_name: borrow_system-backend
|
container_name: borrow_system-backend
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8102:8102"
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
- borrow_system-internal
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
DB_USER: root
|
DB_USER: root
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_NAME: borrow_system
|
DB_NAME: borrow_system
|
||||||
@@ -34,10 +56,20 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||||
MYSQL_DATABASE: borrow_system
|
MYSQL_DATABASE: borrow_system
|
||||||
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
||||||
ports:
|
ports:
|
||||||
- "3309:3306"
|
- "3309:3306"
|
||||||
|
networks:
|
||||||
|
- borrow_system-internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxynet:
|
||||||
|
external: true
|
||||||
|
borrow_system-internal:
|
||||||
|
external: false
|
||||||
|
@@ -7,6 +7,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8001
|
EXPOSE 8101
|
||||||
|
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
@@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/shapes.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Ausleihsystem</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.85.0",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
@@ -1836,9 +1836,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.85.3",
|
"version": "5.85.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
|
||||||
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==",
|
"integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1846,12 +1846,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.85.3",
|
"version": "5.85.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz",
|
||||||
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==",
|
"integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.85.3"
|
"@tanstack/query-core": "5.85.5"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.85.0",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
@@ -41,4 +41,4 @@
|
|||||||
"typescript-eslint": "^8.39.0",
|
"typescript-eslint": "^8.39.0",
|
||||||
"vite": "^7.1.0"
|
"vite": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
frontend/public/shapes.svg
Normal file
1
frontend/public/shapes.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-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>
|
After Width: | Height: | Size: 420 B |
@@ -1 +0,0 @@
|
|||||||
<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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -6,7 +6,11 @@ import Form2 from "./components/Form2";
|
|||||||
import Form4 from "./components/Form4";
|
import Form4 from "./components/Form4";
|
||||||
import LoginForm from "./components/LoginForm";
|
import LoginForm from "./components/LoginForm";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { fetchAllData, ALL_ITEMS_UPDATED_EVENT } from "./utils/fetchData";
|
import {
|
||||||
|
fetchAllData,
|
||||||
|
ALL_ITEMS_UPDATED_EVENT,
|
||||||
|
AUTH_LOGOUT_EVENT,
|
||||||
|
} from "./utils/fetchData";
|
||||||
import { myToast } from "./utils/toastify";
|
import { myToast } from "./utils/toastify";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -18,18 +22,23 @@ function App() {
|
|||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
fetchAllData(token);
|
fetchAllData(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("borrowableItems", JSON.stringify([]));
|
localStorage.setItem("borrowableItems", JSON.stringify([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mock flow without real logic: show the three sections stacked for design preview
|
useEffect(() => {
|
||||||
|
const onAuthLogout = () => {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
};
|
||||||
|
window.addEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
|
||||||
|
return () => window.removeEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
Cookies.remove("token");
|
Cookies.remove("token");
|
||||||
localStorage.removeItem("allItems");
|
localStorage.removeItem("allItems");
|
||||||
localStorage.removeItem("allLoans");
|
localStorage.removeItem("allLoans");
|
||||||
localStorage.removeItem("userLoans");
|
localStorage.removeItem("userLoans");
|
||||||
localStorage.removeItem("borrowableItems");
|
localStorage.removeItem("borrowableItems");
|
||||||
// Let listeners refresh from empty state
|
|
||||||
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
myToast("Logged out successfully!", "success");
|
myToast("Logged out successfully!", "success");
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
@@ -37,11 +46,11 @@ function App() {
|
|||||||
|
|
||||||
return isLoggedIn ? (
|
return isLoggedIn ? (
|
||||||
<Layout onLogout={handleLogout}>
|
<Layout onLogout={handleLogout}>
|
||||||
<div className="space-y-10">
|
<div className="space-y-6">
|
||||||
<Form1 />
|
<Form1 />
|
||||||
<div className="h-px bg-blue-100" />
|
<div className="h-px bg-slate-200" />
|
||||||
<Form2 />
|
<Form2 />
|
||||||
<div className="h-px bg-blue-100" />
|
<div className="h-px bg-slate-200" />
|
||||||
<Form4 />
|
<Form4 />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
12
frontend/src/components/Footer.tsx
Normal file
12
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<footer className="fixed bottom-0 left-0 text-sm w-full bg-slate-100 text-center py-2 border-t border-slate-200 z-50">
|
||||||
|
<p>Made with ❤️ by Theis Gaedigk - Jahrgang 2019</p>
|
||||||
|
<p>v1.1</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
@@ -4,10 +4,12 @@ import { getBorrowableItems } from "../utils/fetchData";
|
|||||||
|
|
||||||
const Form1: React.FC = () => {
|
const Form1: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold text-blue-700">1. Zeitraum wählen</h2>
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||||
|
1. Zeitraum wählen
|
||||||
|
</h2>
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-3"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.currentTarget as HTMLFormElement;
|
const form = e.currentTarget as HTMLFormElement;
|
||||||
@@ -17,40 +19,41 @@ const Form1: React.FC = () => {
|
|||||||
Cookies.set("startDate", start);
|
Cookies.set("startDate", start);
|
||||||
Cookies.set("endDate", end);
|
Cookies.set("endDate", end);
|
||||||
getBorrowableItems();
|
getBorrowableItems();
|
||||||
console.log("Zeitraum erfolgreich gesetzt!");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label
|
<div>
|
||||||
htmlFor="startDate"
|
<label
|
||||||
className="block text-sm font-medium text-blue-800 mb-1"
|
htmlFor="startDate"
|
||||||
>
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
Start
|
>
|
||||||
</label>
|
Start
|
||||||
<input
|
</label>
|
||||||
type="datetime-local"
|
<input
|
||||||
id="startDate"
|
type="datetime-local"
|
||||||
name="startDate"
|
id="startDate"
|
||||||
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70"
|
name="startDate"
|
||||||
/>
|
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<label
|
<div>
|
||||||
htmlFor="endDate"
|
<label
|
||||||
className="block text-sm font-medium text-blue-800 mb-1"
|
htmlFor="endDate"
|
||||||
>
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
Ende
|
>
|
||||||
</label>
|
Ende
|
||||||
<input
|
</label>
|
||||||
type="datetime-local"
|
<input
|
||||||
id="endDate"
|
type="datetime-local"
|
||||||
name="endDate"
|
id="endDate"
|
||||||
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70"
|
name="endDate"
|
||||||
/>
|
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
|
||||||
>
|
>
|
||||||
Verfügbare Gegenstände anzeigen
|
Verfügbare Gegenstände anzeigen
|
||||||
</button>
|
</button>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { createLoan } from "../utils/userHandler";
|
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
|
||||||
import { addToRemove, rmFromRemove } from "../utils/userHandler";
|
|
||||||
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
||||||
|
|
||||||
interface BorrowItem {
|
interface BorrowItem {
|
||||||
@@ -13,14 +12,6 @@ interface BorrowItem {
|
|||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "borrowableItems";
|
const LOCAL_STORAGE_KEY = "borrowableItems";
|
||||||
|
|
||||||
// Einfache Type-Guard/Validierung
|
|
||||||
const isBorrowItem = (v: any): v is BorrowItem =>
|
|
||||||
v &&
|
|
||||||
typeof v.id === "number" &&
|
|
||||||
(typeof v.item_name === "string" || typeof v.name === "string") &&
|
|
||||||
(typeof v.can_borrow_role === "string" || typeof v.role === "string");
|
|
||||||
|
|
||||||
// Helfer: unterschiedliche Server-Shapes normalisieren
|
|
||||||
function normalizeBorrowable(data: any): BorrowItem[] {
|
function normalizeBorrowable(data: any): BorrowItem[] {
|
||||||
const rawArr = Array.isArray(data)
|
const rawArr = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
@@ -52,7 +43,6 @@ function normalizeBorrowable(data: any): BorrowItem[] {
|
|||||||
.filter(Boolean) as BorrowItem[];
|
.filter(Boolean) as BorrowItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook, der automatisch aus dem Local Storage liest und auf Änderungen hört
|
|
||||||
function useBorrowableItems() {
|
function useBorrowableItems() {
|
||||||
const [items, setItems] = React.useState<BorrowItem[]>([]);
|
const [items, setItems] = React.useState<BorrowItem[]>([]);
|
||||||
|
|
||||||
@@ -68,16 +58,13 @@ function useBorrowableItems() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Initial read
|
|
||||||
readFromStorage();
|
readFromStorage();
|
||||||
|
|
||||||
// Cross-tab updates
|
|
||||||
const onStorage = (e: StorageEvent) => {
|
const onStorage = (e: StorageEvent) => {
|
||||||
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
|
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
|
||||||
};
|
};
|
||||||
window.addEventListener("storage", onStorage);
|
window.addEventListener("storage", onStorage);
|
||||||
|
|
||||||
// Same-tab updates via Custom Event
|
|
||||||
const onBorrowableUpdated = () => readFromStorage();
|
const onBorrowableUpdated = () => readFromStorage();
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
BORROWABLE_ITEMS_UPDATED_EVENT,
|
BORROWABLE_ITEMS_UPDATED_EVENT,
|
||||||
@@ -100,58 +87,85 @@ const Form2: React.FC = () => {
|
|||||||
const items = useBorrowableItems();
|
const items = useBorrowableItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold text-blue-700">
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||||
2. Gegenstand auswählen
|
2. Gegenstand auswählen
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="text-red-600 font-medium text-center bg-red-50 border border-red-200 rounded-xl p-4">
|
<div className="text-slate-700 text-center bg-slate-100 border border-slate-200 rounded-xl p-4">
|
||||||
Keine Gegenstände verfügbar für diesen Zeitraum.
|
Keine Gegenstände verfügbar für diesen Zeitraum.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-xl border border-blue-100 shadow-sm bg-white/80">
|
<>
|
||||||
<table className="min-w-full divide-y divide-blue-100">
|
{/* Mobile: card list */}
|
||||||
<thead className="bg-blue-50/60">
|
<div className="sm:hidden space-y-2">
|
||||||
<tr>
|
{items.map((item) => (
|
||||||
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700">
|
<label
|
||||||
Gegenstand
|
key={item.id}
|
||||||
</th>
|
htmlFor={`item-${item.id}`}
|
||||||
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700">
|
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-200 bg-white shadow-sm"
|
||||||
<input type="checkbox" className="invisible" />
|
>
|
||||||
</th>
|
<div className="min-w-0">
|
||||||
</tr>
|
<div className="text-sm font-medium text-slate-900 truncate">
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-blue-50">
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr
|
|
||||||
key={item.id}
|
|
||||||
className="hover:bg-blue-50/40 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 text-sm font-medium text-blue-900">
|
|
||||||
{item.item_name}
|
{item.item_name}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-2 text-sm text-blue-700">
|
<div className="text-xs text-slate-500">
|
||||||
<input
|
{item.inSafe ? "Verfügbar" : "Nicht im Schließfach"}
|
||||||
type="checkbox"
|
</div>
|
||||||
onChange={(e) => {
|
</div>
|
||||||
if (e.target.checked) {
|
<input
|
||||||
addToRemove(item.id);
|
type="checkbox"
|
||||||
} else {
|
id={`item-${item.id}`}
|
||||||
rmFromRemove(item.id);
|
onChange={(e) => {
|
||||||
}
|
if (e.target.checked) addToRemove(item.id);
|
||||||
}}
|
else rmFromRemove(item.id);
|
||||||
id={`item-${item.id}`}
|
}}
|
||||||
/>
|
className="h-5 w-5 accent-indigo-600"
|
||||||
</td>
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="hidden sm:block overflow-x-auto rounded-xl border border-slate-200 shadow-sm bg-white">
|
||||||
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
|
||||||
|
Gegenstand
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
|
||||||
|
<input type="checkbox" className="invisible" />
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-slate-100">
|
||||||
</table>
|
{items.map((item) => (
|
||||||
</div>
|
<tr key={item.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-2 text-sm font-medium text-slate-900">
|
||||||
|
{item.item_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-slate-700 text-right">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) addToRemove(item.id);
|
||||||
|
else rmFromRemove(item.id);
|
||||||
|
}}
|
||||||
|
id={`item-${item.id}`}
|
||||||
|
className="h-4 w-4 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
<div className="flex flex-col sm:flex-row gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createLoan(
|
createLoan(
|
||||||
@@ -160,7 +174,7 @@ const Form2: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1 sm:flex-none sm:w-40 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"
|
className="w-full sm:w-44 bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
|
||||||
>
|
>
|
||||||
Ausleihen
|
Ausleihen
|
||||||
</button>
|
</button>
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { Trash, ArrowLeftRight } from "lucide-react";
|
import { Trash, ArrowLeftRight } from "lucide-react";
|
||||||
import { handleDeleteLoan } from "../utils/userHandler";
|
import { handleDeleteLoan } from "../utils/userHandler";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { queryClient } from "../utils/queryClient";
|
||||||
|
import { onTake, onReturn } from "../utils/userHandler";
|
||||||
|
|
||||||
type Loan = {
|
type Loan = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -8,58 +12,77 @@ type Loan = {
|
|||||||
loan_code: number;
|
loan_code: number;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
|
take_date: string | null;
|
||||||
returned_date: string | null;
|
returned_date: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
loaned_items_id: number[];
|
loaned_items_id: number[];
|
||||||
loaned_items_name: string[];
|
loaned_items_name: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
const formatDate = (iso: string | null) => {
|
const formatDate = (iso: string | null) => {
|
||||||
if (!iso) return "-";
|
if (!iso) return "-";
|
||||||
const d = new Date(iso);
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
if (!m) return iso;
|
||||||
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
const [, y, M, d, h, min] = m;
|
||||||
|
return `${d}.${M}.${y} ${h}:${min}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const readUserLoansFromStorage = (): Loan[] => {
|
async function fetchUserLoans(): Promise<Loan[]> {
|
||||||
const raw = localStorage.getItem("userLoans");
|
const res = await fetch(`${API_BASE}/api/userLoans`, {
|
||||||
if (!raw || raw === '"No loans found for this user"') return [];
|
method: "GET",
|
||||||
try {
|
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
|
||||||
const parsed = JSON.parse(raw);
|
});
|
||||||
return Array.isArray(parsed) ? (parsed as Loan[]) : [];
|
if (!res.ok) throw new Error("Failed to fetch user loans");
|
||||||
} catch {
|
const data = await res.json();
|
||||||
return [];
|
if (data === "No loans found for this user") return [];
|
||||||
}
|
return Array.isArray(data) ? (data as Loan[]) : [];
|
||||||
};
|
}
|
||||||
|
|
||||||
const Form4: React.FC = () => {
|
const Form4: React.FC = () => {
|
||||||
const [userLoans, setUserLoans] = useState<Loan[]>(() =>
|
const { data: userLoans = [], isFetching } = useQuery({
|
||||||
readUserLoansFromStorage()
|
queryKey: ["userLoans"],
|
||||||
);
|
queryFn: fetchUserLoans,
|
||||||
|
});
|
||||||
|
|
||||||
// Keep in sync if localStorage changes (e.g., other tabs or parts of the app)
|
const deleteMutation = useMutation({
|
||||||
useEffect(() => {
|
mutationFn: (loanID: number) => handleDeleteLoan(loanID),
|
||||||
const onStorage = (e: StorageEvent) => {
|
onSuccess: () => {
|
||||||
if (e.key === "userLoans") {
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
setUserLoans(readUserLoansFromStorage());
|
},
|
||||||
}
|
});
|
||||||
};
|
|
||||||
window.addEventListener("storage", onStorage);
|
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onDelete = async (loanID: number) => {
|
const takeMutation = useMutation({
|
||||||
const ok = await handleDeleteLoan(loanID);
|
mutationFn: (loanID: number) => onTake(loanID),
|
||||||
if (ok) {
|
onSuccess: () => {
|
||||||
setUserLoans((prev) =>
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
prev.filter((l) => Number(l.id) !== Number(loanID))
|
},
|
||||||
);
|
});
|
||||||
}
|
|
||||||
};
|
const returnMutation = useMutation({
|
||||||
|
mutationFn: (loanID: number) => onReturn(loanID),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = (loanID: number) => deleteMutation.mutate(loanID);
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
|
||||||
|
<p>Lade Ausleihen…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (userLoans.length === 0) {
|
if (userLoans.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-6 text-center text-gray-600 shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
|
||||||
<p>Keine Ausleihen gefunden.</p>
|
<p>Keine Ausleihen gefunden.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -67,75 +90,169 @@ const Form4: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-lg font-semibold tracking-tight text-gray-900">
|
<p className="text-lg font-semibold tracking-tight text-slate-900">
|
||||||
Meine Ausleihen
|
Meine Ausleihen
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-slate-600">
|
||||||
Wenn du eine Ausleihe ändern oder löschen möchtest, klicke auf das
|
Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
|
||||||
Papierkorb-Symbol.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
{/* Mobile: cards */}
|
||||||
|
<div className="space-y-2 sm:hidden">
|
||||||
|
{userLoans.map((loan) => (
|
||||||
|
<div
|
||||||
|
key={loan.id}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-slate-900">
|
||||||
|
Leihcode: <span className="font-mono">{loan.loan_code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-700">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Start:</span>{" "}
|
||||||
|
{formatDate(loan.start_date)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Ende:</span>{" "}
|
||||||
|
{formatDate(loan.end_date)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Abgeholt:</span>{" "}
|
||||||
|
{loan.take_date ? (
|
||||||
|
formatDate(loan.take_date)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
|
||||||
|
onClick={() => takeMutation.mutate(loan.id)}
|
||||||
|
disabled={takeMutation.isPending}
|
||||||
|
>
|
||||||
|
{takeMutation.isPending ? "..." : "Abholen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Zurück:</span>{" "}
|
||||||
|
{loan.returned_date ? (
|
||||||
|
formatDate(loan.returned_date)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
|
||||||
|
onClick={() => returnMutation.mutate(loan.id)}
|
||||||
|
disabled={returnMutation.isPending || !loan.take_date}
|
||||||
|
title={!loan.take_date ? "Erst abholen" : ""}
|
||||||
|
>
|
||||||
|
{returnMutation.isPending ? "..." : "Zurückgeben"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-slate-700">
|
||||||
|
<span className="text-slate-500">Gegenstände:</span>{" "}
|
||||||
|
{Array.isArray(loan.loaned_items_name)
|
||||||
|
? loan.loaned_items_name.join(", ")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(loan.id)}
|
||||||
|
aria-label="Ausleihe löschen"
|
||||||
|
className="flex items-center justify-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="hidden sm:block rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm text-gray-700">
|
<table className="table-auto min-w-full text-sm text-slate-700">
|
||||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
<thead className="sticky top-0 z-10 bg-slate-50">
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-slate-200">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Leihcode
|
Leihcode
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Start
|
Start
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Ende
|
Ende
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Abgeholt
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Zurückgegeben
|
Zurückgegeben
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Erstellt
|
Erstellt
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Gegenstände
|
Gegenstände
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-600">
|
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
Aktionen
|
Aktionen
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{userLoans.map((loan) => (
|
{userLoans.map((loan) => (
|
||||||
<tr
|
<tr key={loan.id} className="odd:bg-white even:bg-slate-50">
|
||||||
key={loan.id}
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100/60 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
|
|
||||||
{loan.loan_code}
|
{loan.loan_code}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
{formatDate(loan.start_date)}
|
{formatDate(loan.start_date)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
{formatDate(loan.end_date)}
|
{formatDate(loan.end_date)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
{formatDate(loan.returned_date)}
|
{loan.take_date ? (
|
||||||
|
formatDate(loan.take_date)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
|
||||||
|
onClick={() => takeMutation.mutate(loan.id)}
|
||||||
|
disabled={takeMutation.isPending}
|
||||||
|
>
|
||||||
|
{takeMutation.isPending ? "..." : "Abholen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
|
{loan.returned_date ? (
|
||||||
|
formatDate(loan.returned_date)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
|
||||||
|
onClick={() => returnMutation.mutate(loan.id)}
|
||||||
|
disabled={returnMutation.isPending || !loan.take_date}
|
||||||
|
title={!loan.take_date ? "Erst abholen" : ""}
|
||||||
|
>
|
||||||
|
{returnMutation.isPending ? "..." : "Zurückgeben"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
{formatDate(loan.created_at)}
|
{formatDate(loan.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
<div className="max-w-[22rem] truncate text-gray-900">
|
<div className="text-slate-900">
|
||||||
{Array.isArray(loan.loaned_items_name)
|
{Array.isArray(loan.loaned_items_name)
|
||||||
? loan.loaned_items_name.join(", ")
|
? loan.loaned_items_name.join(", ")
|
||||||
: "-"}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(loan.id)}
|
onClick={() => onDelete(loan.id)}
|
||||||
aria-label="Ausleihe löschen"
|
aria-label="Ausleihe löschen"
|
||||||
className="inline-flex items-center rounded-md p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
|
className="inline-flex items-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
|
||||||
>
|
>
|
||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -145,7 +262,6 @@ const Form4: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll hint */}
|
{/* Scroll hint */}
|
||||||
<div className="border-t border-gray-100 px-4 py-2">
|
<div className="border-t border-gray-100 px-4 py-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
@@ -1,25 +1,74 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { changePW } from "../utils/userHandler";
|
||||||
|
import { myToast } from "../utils/toastify";
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ onLogout }) => {
|
const Header: React.FC<HeaderProps> = ({ onLogout }) => {
|
||||||
|
const passwordForm = () => {
|
||||||
|
const oldPW = window.prompt("Altes Passwort");
|
||||||
|
const newPW = window.prompt("Neues Passwort");
|
||||||
|
const repeatNewPW = window.prompt("Neues Passwort wiederholen");
|
||||||
|
if (oldPW && newPW && repeatNewPW) {
|
||||||
|
if (newPW === repeatNewPW) {
|
||||||
|
changePW(oldPW, newPW);
|
||||||
|
} else {
|
||||||
|
myToast("Die neuen Passwörter stimmen nicht überein.", "error");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
myToast("Bitte alle Felder ausfüllen.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
"inline-flex items-center h-9 px-3 rounded-md text-sm font-medium border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 active:bg-slate-200 transition focus:outline-none focus:ring-2 focus:ring-slate-400/50";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="mb-6 md:mb-10">
|
<header className="mb-4 sm:mb-6">
|
||||||
<h1 className="text-3xl md:text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
Gegenstand ausleihen
|
<div className="min-w-0">
|
||||||
</h1>
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-slate-900 tracking-tight">
|
||||||
<p className="text-blue-500 mt-1 md:mt-2 text-base md:text-lg font-medium">
|
Gegenstand ausleihen
|
||||||
Schnell und unkompliziert Equipment reservieren
|
</h1>
|
||||||
</p>
|
<p className="text-slate-600 mt-1 text-sm sm:text-base">
|
||||||
<button
|
Schnell und unkompliziert Equipment reservieren
|
||||||
type="button"
|
</p>
|
||||||
onClick={onLogout}
|
</div>
|
||||||
className="text-blue-500 hover:underline"
|
|
||||||
>
|
<nav
|
||||||
Logout
|
aria-label="Aktionen"
|
||||||
</button>
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/src/branch/dev/Docs/HELP.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
Hilfe
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={passwordForm} className={btn}>
|
||||||
|
Passwort ändern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className={`${btn} border-rose-300 hover:bg-rose-50`}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import Footer from "./Footer";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { loginUser } from "../utils/fetchData";
|
import { loginUser } from "../utils/fetchData";
|
||||||
import { myToast } from "../utils/toastify";
|
import { myToast } from "../utils/toastify";
|
||||||
@@ -22,16 +23,16 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-950 min-h-screen">
|
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
|
||||||
<div className="max-w-sm mx-auto mt-20 bg-white rounded-xl shadow-lg p-8 border border-blue-100">
|
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-6 sm:p-8 border border-slate-200">
|
||||||
<h2 className="text-2xl font-bold text-blue-700 mb-6 text-center">
|
<h2 className="text-2xl font-bold text-slate-900 mb-6 text-center">
|
||||||
Login
|
Login
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="username"
|
htmlFor="username"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
>
|
>
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
@@ -39,14 +40,14 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
id="username"
|
id="username"
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 px-3 py-2"
|
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
>
|
>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
@@ -54,18 +55,19 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 px-3 py-2"
|
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow hover:bg-blue-700 transition"
|
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-md shadow hover:bg-indigo-700 transition"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -7,9 +7,9 @@ type ObjectProps = {
|
|||||||
|
|
||||||
const Object: React.FC<ObjectProps> = ({ title, description }) => {
|
const Object: React.FC<ObjectProps> = ({ title, description }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-blue-800">{title}</h3>
|
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||||
<p className="text-xs text-blue-500/80 line-clamp-2">{description}</p>
|
<p className="text-xs text-slate-600 line-clamp-2">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import Object from "./Object";
|
import Object from "./Object";
|
||||||
|
import { MonitorSmartphone } from "lucide-react";
|
||||||
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
||||||
|
|
||||||
const Sidebar: React.FC = () => {
|
const Sidebar: React.FC = () => {
|
||||||
@@ -12,82 +13,83 @@ const Sidebar: React.FC = () => {
|
|||||||
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
|
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
|
||||||
setItems(next);
|
setItems(next);
|
||||||
};
|
};
|
||||||
// Update immediately in case data changed before this mounted
|
|
||||||
handler();
|
handler();
|
||||||
window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
|
window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
|
||||||
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
|
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
|
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
|
||||||
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe)); // außerhalb zuerst
|
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full md:w-80 md:h-screen md:sticky md:top-0 overflow-y-auto bg-white/90 backdrop-blur md:border-r border-blue-100 shadow-xl flex flex-col p-6">
|
<aside className="w-full md:w-72 md:h-full flex flex-col rounded-2xl pt-0 px-3 pb-3 sm:pt-0 sm:px-4 sm:pb-4 bg-gradient-to-b from-white to-slate-50 ring-1 ring-slate-200/70 shadow-md overflow-hidden">
|
||||||
<h2 className="text-2xl font-extrabold mb-4 text-blue-700 tracking-tight flex items-center justify-between">
|
<div className="sticky top-0 z-10 -mx-3 sm:-mx-4 px-3 sm:px-4 py-2.5 bg-white/85 backdrop-blur supports-[backdrop-filter]:backdrop-blur border-b border-slate-200/70 text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between gap-2 rounded-t-2xl">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 min-w-0 flex-1 truncate">
|
||||||
<svg
|
<MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
|
||||||
className="w-6 h-6 text-blue-500"
|
<span className="truncate">Geräte</span>
|
||||||
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>
|
|
||||||
Geräte Übersicht
|
|
||||||
</span>
|
</span>
|
||||||
{outCount > 0 && (
|
{outCount > 0 && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">
|
<span className="inline-flex items-center gap-1 whitespace-nowrap tabular-nums text-[10px] sm:text-xs px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 ring-1 ring-amber-200/70 shadow-sm font-medium">
|
||||||
{outCount} außerhalb
|
{outCount} außerhalb
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4 flex-1 pr-1">
|
|
||||||
{sorted.map((item: any) => (
|
|
||||||
<div
|
|
||||||
key={item.item_name}
|
|
||||||
className={`bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition ${
|
|
||||||
item.inSafe ? "" : "ring-1 ring-red-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="relative mt-1 inline-flex" aria-hidden="true">
|
|
||||||
{!item.inSafe && (
|
|
||||||
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`inline-block w-3 h-3 rounded-full ${
|
|
||||||
item.inSafe ? "bg-green-400" : "bg-red-500"
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<Object
|
|
||||||
title={item.item_name}
|
|
||||||
description={
|
|
||||||
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 text-xs text-gray-400 flex items-center gap-4">
|
{/* Scroll area */}
|
||||||
<span className="inline-block w-3 h-3 bg-green-400 rounded-full"></span>
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||||
Verfügbar
|
<div className="flex flex-col gap-3 md:space-y-3">
|
||||||
<span className="inline-block w-3 h-3 bg-red-400 rounded-full"></span>
|
{sorted.map((item: any) => (
|
||||||
Außerhalb des Schließfachs
|
<div
|
||||||
|
key={item.item_name}
|
||||||
|
className={`group relative w-full bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200/70 duration-200 hover:shadow-md focus-within:ring-slate-300 ${
|
||||||
|
item.inSafe
|
||||||
|
? "border-l-4 border-emerald-400"
|
||||||
|
: "border-l-4 border-red-400 ring-red-200/60 bg-red-50/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className="relative mt-0.5 inline-flex"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{!item.inSafe && (
|
||||||
|
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
|
||||||
|
item.inSafe ? "bg-emerald-500" : "bg-red-500"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Object
|
||||||
|
title={item.item_name}
|
||||||
|
description={
|
||||||
|
item.inSafe
|
||||||
|
? "Aktuell im Schließfach"
|
||||||
|
: "Aktuell nicht im Schließfach"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-200/70 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white shadow-sm"></span>
|
||||||
|
Im Schließfach
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-3 bg-red-500 rounded-full ring-2 ring-white shadow-sm"></span>
|
||||||
|
Außerhalb des Schließfachs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
@@ -1 +1,12 @@
|
|||||||
@import "tailwindcss";
|
/* Tailwind (v4) */
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Small touch target improvements */
|
||||||
|
@layer base {
|
||||||
|
html:focus-within {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import "../App.css";
|
import "../App.css";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Sidebar from "../components/Sidebar";
|
import Sidebar from "../components/Sidebar";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,28 +11,24 @@ type LayoutProps = {
|
|||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
|
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
|
<div className="h-screen flex flex-col bg-slate-50 text-slate-800">
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<main className="flex-1 flex flex-col items-center py-10 px-4 md:py-14">
|
<main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8 pb-12">
|
||||||
<div className="w-full max-w-3xl">
|
<div className="w-full max-w-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
|
||||||
<Header onLogout={onLogout} />
|
<div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
|
||||||
</div>
|
<Sidebar />
|
||||||
<div className="w-full max-w-3xl bg-white/90 shadow-2xl rounded-3xl p-6 md:p-10 ring-1 ring-blue-100">
|
</div>
|
||||||
{children}
|
<div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
|
||||||
|
<div className="w-full">
|
||||||
|
<Header onLogout={onLogout} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200 flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
{/* Mobile sidebar at bottom */}
|
|
||||||
<div className="fixed bottom-4 left-4 right-4 md:hidden z-10">
|
|
||||||
<div className="bg-white/95 backdrop-blur rounded-2xl shadow-xl ring-1 ring-blue-100 p-4">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -2,24 +2,28 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer, Flip } from "react-toastify";
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "./utils/queryClient";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastContainer
|
<App />
|
||||||
position="top-right"
|
<ToastContainer
|
||||||
autoClose={3000}
|
position="top-right"
|
||||||
hideProgressBar={false}
|
autoClose={3000}
|
||||||
newestOnTop
|
hideProgressBar={false}
|
||||||
closeOnClick
|
newestOnTop
|
||||||
rtl={false}
|
closeOnClick
|
||||||
pauseOnFocusLoss={false}
|
rtl={false}
|
||||||
draggable
|
pauseOnFocusLoss
|
||||||
pauseOnHover
|
draggable
|
||||||
theme="light"
|
pauseOnHover
|
||||||
style={{ zIndex: 9999 }}
|
theme="colored"
|
||||||
/>
|
transition={Flip}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
@@ -4,18 +4,49 @@ import { myToast } from "./toastify";
|
|||||||
// Event name used to notify the app when the list of items has been updated
|
// Event name used to notify the app when the list of items has been updated
|
||||||
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
|
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
|
||||||
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
|
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
|
||||||
|
export const AUTH_LOGOUT_EVENT = "authLogout";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
|
let sendError = false;
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
Cookies.remove("token");
|
||||||
|
Cookies.remove("startDate");
|
||||||
|
Cookies.remove("endDate");
|
||||||
|
localStorage.removeItem("allItems");
|
||||||
|
localStorage.removeItem("allLoans");
|
||||||
|
localStorage.removeItem("userLoans");
|
||||||
|
localStorage.removeItem("borrowableItems");
|
||||||
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
|
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT));
|
||||||
|
window.dispatchEvent(new Event(AUTH_LOGOUT_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchAllData = async (token: string | undefined) => {
|
export const fetchAllData = async (token: string | undefined) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
// First we fetch all items that are potentially available for borrowing
|
// First we fetch all items that are potentially available for borrowing
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/api/items", {
|
const response = await fetch(`${API_BASE}/api/items`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 500) {
|
||||||
|
if (!sendError) {
|
||||||
|
sendError = true;
|
||||||
|
myToast("Session expired. Please log in again.", "error");
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
myToast("Failed to fetch items", "error");
|
myToast("Failed to fetch items", "error");
|
||||||
return;
|
return;
|
||||||
@@ -31,13 +62,23 @@ export const fetchAllData = async (token: string | undefined) => {
|
|||||||
|
|
||||||
// get all loans
|
// get all loans
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/api/loans", {
|
const response = await fetch(`${API_BASE}/api/loans`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 500) {
|
||||||
|
if (!sendError) {
|
||||||
|
sendError = true;
|
||||||
|
myToast("Session expired. Please log in again.", "error");
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
myToast("Failed to fetch loans!", "error");
|
myToast("Failed to fetch loans!", "error");
|
||||||
return;
|
return;
|
||||||
@@ -53,13 +94,23 @@ export const fetchAllData = async (token: string | undefined) => {
|
|||||||
|
|
||||||
// get user loans
|
// get user loans
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/api/userLoans", {
|
const response = await fetch(`${API_BASE}/api/userLoans`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 500) {
|
||||||
|
if (!sendError) {
|
||||||
|
sendError = true;
|
||||||
|
myToast("Session expired. Please log in again.", "error");
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
myToast("Failed to fetch user loans!", "error");
|
myToast("Failed to fetch user loans!", "error");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +127,7 @@ export const fetchAllData = async (token: string | undefined) => {
|
|||||||
|
|
||||||
export const loginUser = async (username: string, password: string) => {
|
export const loginUser = async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/api/login", {
|
const response = await fetch(`${API_BASE}/api/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -112,7 +163,7 @@ export const getBorrowableItems = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/api/borrowableItems", {
|
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
@@ -122,6 +173,16 @@ export const getBorrowableItems = async () => {
|
|||||||
body: JSON.stringify({ startDate, endDate }),
|
body: JSON.stringify({ startDate, endDate }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 500) {
|
||||||
|
if (!sendError) {
|
||||||
|
sendError = true;
|
||||||
|
myToast("Session expired. Please log in again.", "error");
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
myToast("Failed to fetch borrowable items", "error");
|
myToast("Failed to fetch borrowable items", "error");
|
||||||
return;
|
return;
|
||||||
|
11
frontend/src/utils/queryClient.ts
Normal file
11
frontend/src/utils/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Central QueryClient instance so utilities (e.g. file upload) can invalidate queries.
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@@ -1,17 +1,18 @@
|
|||||||
import { toast, type ToastOptions } from "react-toastify";
|
import { toast, Flip, type ToastOptions } from "react-toastify";
|
||||||
|
|
||||||
export type ToastType = "success" | "error" | "info" | "warning";
|
export type ToastType = "success" | "error" | "info" | "warning";
|
||||||
|
|
||||||
export const myToast = (message: string, msgType: ToastType) => {
|
export const myToast = (message: string, msgType: ToastType) => {
|
||||||
let config: ToastOptions = {
|
let config: ToastOptions = {
|
||||||
position: "top-right",
|
position: "top-right",
|
||||||
autoClose: 5000,
|
autoClose: 3000,
|
||||||
hideProgressBar: false,
|
hideProgressBar: false,
|
||||||
closeOnClick: true,
|
closeOnClick: true,
|
||||||
pauseOnHover: true,
|
pauseOnHover: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
progress: undefined,
|
progress: undefined,
|
||||||
theme: "light",
|
theme: "colored",
|
||||||
|
transition: Flip,
|
||||||
};
|
};
|
||||||
toast[msgType](message, config);
|
toast[msgType](message, config);
|
||||||
};
|
};
|
||||||
|
@@ -1,10 +1,16 @@
|
|||||||
import { myToast } from "./toastify";
|
import { myToast } from "./toastify";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import { queryClient } from "./queryClient";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
|
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`http://localhost:8002/api/deleteLoan/${loanID}`,
|
`${API_BASE}/api/deleteLoan/${loanID}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -74,7 +80,7 @@ export const rmFromRemove = (itemID: number) => {
|
|||||||
|
|
||||||
export const createLoan = async (startDate: string, endDate: string) => {
|
export const createLoan = async (startDate: string, endDate: string) => {
|
||||||
const items = removeArr;
|
const items = removeArr;
|
||||||
const response = await fetch("http://localhost:8002/api/createLoan", {
|
const response = await fetch(`${API_BASE}/api/createLoan`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -92,5 +98,66 @@ export const createLoan = async (startDate: string, endDate: string) => {
|
|||||||
removeArr = [];
|
removeArr = [];
|
||||||
Cookies.set("removeArr", "[]");
|
Cookies.set("removeArr", "[]");
|
||||||
myToast("Ausleihe erfolgreich erstellt!", "success");
|
myToast("Ausleihe erfolgreich erstellt!", "success");
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onReturn = async (loanID: number) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/returnLoan/${loanID}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
myToast("Fehler beim Zurückgeben der Ausleihe", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
myToast("Ausleihe erfolgreich zurückgegeben!", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onTake = async (loanID: number) => {
|
||||||
|
const response = await fetch(`${API_BASE}/api/takeLoan/${loanID}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
myToast("Fehler beim Ausleihen der Ausleihe", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
myToast("Ausleihe erfolgreich ausgeliehen!", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changePW = async (oldPassword: string, newPassword: string) => {
|
||||||
|
const response = await fetch(`${API_BASE}/api/changePassword`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ oldPassword, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
myToast("Fehler beim Ändern des Passworts", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
myToast("Passwort erfolgreich geändert!", "success");
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
|
"./index.html",
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
// add other paths if needed
|
// add other paths if needed
|
||||||
],
|
],
|
||||||
|
@@ -1,15 +1,17 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import svgr from "vite-plugin-svgr";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), svgr(), tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 8001,
|
allowedHosts: ["insta.the1s.de"],
|
||||||
watch: {
|
port: 8101,
|
||||||
usePolling: true,
|
watch: { usePolling: true },
|
||||||
|
hmr: {
|
||||||
|
host: "insta.the1s.de",
|
||||||
|
port: 8101,
|
||||||
|
protocol: "wss",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user