Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2145e2722a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -109,6 +109,7 @@ backend/public/uploads/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# API keys and secrets (additional protection)
|
# API keys and secrets (additional protection)
|
||||||
|
config/
|
||||||
secrets/
|
secrets/
|
||||||
keys/
|
keys/
|
||||||
|
|
||||||
|
|||||||
22
Docs/CHANGELOG.md
Normal file
22
Docs/CHANGELOG.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Changelog
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
## Current hosted version
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
> No changelog available.
|
||||||
|
|
||||||
|
## Upcoming changes
|
||||||
|
|
||||||
|
v1.2
|
||||||
|
|
||||||
|
### Fixes and improvements
|
||||||
|
|
||||||
|
- Implement user roles and permissions
|
||||||
|
- Improve form validation and error handling
|
||||||
|
- Add loading indicators for async actions
|
||||||
|
- Optimize performance for large datasets
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- Admin panel for managing users, permissions and all of the system settings and database
|
||||||
@@ -3,5 +3,3 @@
|
|||||||
This document provides an overview of the backend API endpoints and their usage.
|
This document provides an overview of the backend API endpoints and their usage.
|
||||||
|
|
||||||
To get to that information, go to the `backend_API_docs` directory.
|
To get to that information, go to the `backend_API_docs` directory.
|
||||||
|
|
||||||
If you need help, see HELP.md file in this directory.
|
|
||||||
@@ -1,87 +1,58 @@
|
|||||||
# Backend API (V2) Documentation
|
# Backend API docs (apiV2)
|
||||||
|
|
||||||
This document describes the current backend API routes and their real response shapes, based on the code in `backendV2`.
|
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
|
||||||
|
|
||||||
|
On this page you will learn how my API works.
|
||||||
|
|
||||||
|
## General information
|
||||||
|
|
||||||
|
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` which is for my web frontend, because this file works together with my JWT token service.
|
||||||
|
|
||||||
|
But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
|
||||||
|
|
||||||
|
But first you have to get an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Base URLs
|
## Base URL
|
||||||
|
|
||||||
- Frontend: `https://insta.the1s.de`
|
- Frontend: `https://insta.the1s.de`
|
||||||
- Backend: `https://backend.insta.the1s.de`
|
- Backend: `https://backend.insta.the1s.de`
|
||||||
- Base path: `https://backend.insta.the1s.de/api`
|
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
|
||||||
|
|
||||||
Service status: `https://status.the1s.de`
|
You can see the status of this and all my other services at `https://status.the1s.de`.
|
||||||
|
|
||||||
|
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All **protected** endpoints require an API key as a path parameter `:key`.
|
All endpoints require an API key as a path parameter named `:key`.
|
||||||
|
|
||||||
Rules for `:key`:
|
Example: `/apiV2/items/:key`
|
||||||
|
|
||||||
- Exactly 8 characters
|
If the key is missing or invalid, the API responds with `401 Unauthorized`.
|
||||||
- Digits only (`^[0-9]{8}$`)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/items/12345678
|
|
||||||
```
|
|
||||||
|
|
||||||
On missing / invalid key:
|
|
||||||
|
|
||||||
- Status: `401 Unauthorized`
|
|
||||||
- Body (exact message depends on `authenticate` in `backendV2/services/authentication.js`)
|
|
||||||
|
|
||||||
Auth-related modules:
|
|
||||||
|
|
||||||
- `backendV2/services/authentication.js`
|
|
||||||
- `backendV2/services/database.js`
|
|
||||||
|
|
||||||
Route handlers:
|
|
||||||
|
|
||||||
- `backendV2/routes/api/api.route.js`
|
|
||||||
- `backendV2/routes/api/api.database.js`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Endpoints (Overview)
|
## Endpoints
|
||||||
|
|
||||||
1. **Public**
|
### 1) Get all items
|
||||||
- `GET /api/all-items` – List all items (no auth; from original docs)
|
|
||||||
|
|
||||||
2. **Items (authenticated)**
|
GET `/apiV2/items/:key`
|
||||||
- `GET /api/items/:key` – List all items
|
|
||||||
- `POST /api/change-state/:key/:itemId/:state` – Toggle item safe state
|
|
||||||
|
|
||||||
3. **Loans (authenticated)**
|
Returns a list of all items wrapped in a `data` object.
|
||||||
- `GET /api/get-loan-by-code/:key/:loan_code` – Get loan by code
|
|
||||||
- `POST /api/set-take-date/:key/:loan_code` – Set “take” date and mark items as out
|
|
||||||
- `POST /api/set-return-date/:key/:loan_code` – Set “return” date and mark items as returned
|
|
||||||
|
|
||||||
---
|
Example request:
|
||||||
|
|
||||||
## 1) Items
|
```
|
||||||
|
GET https://backend.insta.the1s.de/apiV2/items/12345
|
||||||
### 1.1 Get all items
|
|
||||||
|
|
||||||
**GET** `/api/items/:key`
|
|
||||||
|
|
||||||
Returns all items wrapped in a `data` property.
|
|
||||||
|
|
||||||
- Handler: `getItemsFromDatabaseV2` in `api.database.js`
|
|
||||||
- SQL: `SELECT * FROM items;`
|
|
||||||
|
|
||||||
#### Example request
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET https://backend.insta.the1s.de/api/items/12345678
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Successful response
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
@@ -89,248 +60,151 @@ GET https://backend.insta.the1s.de/api/items/12345678
|
|||||||
"item_name": "DJI 1er Mikro",
|
"item_name": "DJI 1er Mikro",
|
||||||
"can_borrow_role": 4,
|
"can_borrow_role": 4,
|
||||||
"inSafe": 1,
|
"inSafe": 1,
|
||||||
"safe_nr": "01",
|
"entry_created_at": "2025-08-19T22:02:16.000Z"
|
||||||
"entry_created_at": "2025-08-19T22:02:16.000Z",
|
|
||||||
"entry_updated_at": "2025-08-19T22:02:16.000Z",
|
|
||||||
"last_borrowed_person": "alice",
|
|
||||||
"currently_borrowing": null
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error response
|
Fields:
|
||||||
|
|
||||||
```json
|
- `id`: Unique identifier
|
||||||
{ "message": "Failed to fetch items" }
|
- `item_name`: Item name
|
||||||
```
|
- `can_borrow_role`: Role allowed to borrow
|
||||||
|
- `inSafe`: 1 if in locker, 0 otherwise
|
||||||
|
- `entry_created_at`: Creation timestamp
|
||||||
|
|
||||||
#### Status codes
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
- `200 OK` – success, `data` is an array (possibly empty)
|
|
||||||
- `401 Unauthorized` – invalid / missing key
|
|
||||||
- `500 Internal Server Error` – database error or `success: false` from DB layer
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.2 Toggle item safe state
|
### 2) Change item safe state
|
||||||
|
|
||||||
**POST** `/api/change-state/:key/:itemId/:state`
|
POST `/apiV2/controlInSafe/:key/:itemId/:state`
|
||||||
|
|
||||||
> You do not need this endpoint to set the states of the items when the items are taken out or returned. When you take or return a loan, the item states are set automatically by the loan endpoints. This endpoint is only for manually toggling the `inSafe` state of an item.
|
Updates `inSafe` (locker) state of an item.
|
||||||
|
|
||||||
Path parameters:
|
- `state` must be `"1"` (in safe) or `"0"` (not in safe)
|
||||||
|
|
||||||
- `:key` – API key (8 digits)
|
Example request:
|
||||||
- `:itemId` – numeric `id` of the item
|
|
||||||
- `:state` – must be `"1"` or `"0"`
|
|
||||||
|
|
||||||
Handler in `api.route.js` calls `changeInSafeStateV2(itemId)`, which executes:
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
|
||||||
```sql
|
|
||||||
UPDATE items SET inSafe = NOT inSafe WHERE id = ?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Example request
|
Example response (shape depends on database service):
|
||||||
|
|
||||||
```http
|
```
|
||||||
POST https://backend.insta.the1s.de/api/change-state/12345678/42/1
|
{ "data": { /* update result */ } }
|
||||||
```
|
```
|
||||||
|
|
||||||
(Will toggle `inSafe` for item `42`, regardless of the final `1`.)
|
Status:
|
||||||
|
|
||||||
#### Successful response (current implementation)
|
- 200 on success
|
||||||
|
- 400 if `state` is invalid
|
||||||
|
- 500 on failure
|
||||||
|
|
||||||
```json
|
**You can get the item id on the admin panel, from your system administrator.**
|
||||||
{
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Error responses
|
|
||||||
|
|
||||||
Invalid `state` (anything other than `"0"` or `"1"`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "message": "Invalid state value" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Failed update:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "message": "Failed to update item state" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Status codes
|
|
||||||
|
|
||||||
- `200 OK` – item state toggled
|
|
||||||
- `400 Bad Request` – invalid `state` parameter
|
|
||||||
- `401 Unauthorized` – invalid / missing key
|
|
||||||
- `500 Internal Server Error` – database/update failure or `success: false` from DB layer
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3) Loans
|
### 3) Get loan by code
|
||||||
|
|
||||||
### 3.1 Get loan by code
|
GET `/apiV2/getLoanByCode/:key/:loan_code`
|
||||||
|
|
||||||
**GET** `/api/get-loan-by-code/:key/:loan_code`
|
Retrieves the details of a specific loan.
|
||||||
|
|
||||||
Path parameters:
|
Example request:
|
||||||
|
|
||||||
- `:key` – API key
|
```
|
||||||
- `:loan_code` – 6-digit loan code (`^[0-9]{6}$` per DB constraint)
|
GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
|
||||||
|
|
||||||
Database layer (`getLoanByCodeV2`) currently selects:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT first_name, returned_date, take_date, lockers
|
|
||||||
FROM loans
|
|
||||||
WHERE loan_code = ?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Example request
|
Example response:
|
||||||
|
|
||||||
```http
|
|
||||||
GET https://backend.insta.the1s.de/api/get-loan-by-code/12345678/646473
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Successful response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"first_name": "Theis",
|
"id": 6,
|
||||||
|
"username": "theis",
|
||||||
|
"loan_code": 646473,
|
||||||
|
"start_date": "2025-08-25T13:23:00.000Z",
|
||||||
|
"end_date": "2025-08-26T13:23:00.000Z",
|
||||||
|
"take_date": null,
|
||||||
"returned_date": null,
|
"returned_date": null,
|
||||||
"take_date": "2025-08-25T13:23:00.000Z",
|
"created_at": "2025-08-20T11:23:40.000Z",
|
||||||
"lockers": ["01", "03"]
|
"loaned_items_id": [8, 9],
|
||||||
|
"loaned_items_name": ["SD Karten", "Kameragimbal"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error response
|
Status:
|
||||||
|
|
||||||
```json
|
- 200 on success
|
||||||
{ "message": "Loan not found" }
|
- 404 if not found
|
||||||
```
|
|
||||||
|
|
||||||
#### Status codes
|
|
||||||
|
|
||||||
- `200 OK` – loan found
|
|
||||||
- `401 Unauthorized` – invalid / missing key
|
|
||||||
- `404 Not Found` – no matching loan for this `loan_code`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 Set take date
|
### 4) Set return date (now) by loan code
|
||||||
|
|
||||||
**POST** `/api/set-take-date/:key/:loan_code`
|
POST `/apiV2/setReturnDate/:key/:loan_code`
|
||||||
|
|
||||||
Path parameters:
|
Sets the `returned_date` to the current server time.
|
||||||
|
|
||||||
- `:key` – API key
|
**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.
|
||||||
- `:loan_code` – loan code
|
|
||||||
|
|
||||||
#### Example request
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
|
|
||||||
```http
|
Example request:
|
||||||
POST https://backend.insta.the1s.de/api/set-take-date/12345678/646473
|
|
||||||
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Successful response
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{ "data": { /* update result */ } }
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error response
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
```json
|
|
||||||
{ "message": "Failed to set take date" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Status codes
|
|
||||||
|
|
||||||
- `200 OK` – take date set and items marked as out
|
|
||||||
- `401 Unauthorized` – invalid / missing key
|
|
||||||
- `500 Internal Server Error` – invalid loan, missing items, or DB error / `success: false`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.3 Set return date
|
### 5) Set take date (now) by loan code
|
||||||
|
|
||||||
**POST** `/api/set-return-date/:key/:loan_code`
|
POST `/apiV2/setTakeDate/:key/:loan_code`
|
||||||
|
|
||||||
Path parameters:
|
Sets the `take_date` to the current server time.
|
||||||
|
|
||||||
- `:key` – API key
|
**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.
|
||||||
- `:loan_code` – loan code
|
|
||||||
|
|
||||||
#### Example request
|
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||||
|
|
||||||
```http
|
Example request:
|
||||||
POST https://backend.insta.the1s.de/api/set-return-date/12345678/646473
|
|
||||||
|
```
|
||||||
|
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Successful response (current implementation)
|
Example response:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
{ "data": { /* update result */ } }
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Error response
|
Status: 200 on success, 500 on failure.
|
||||||
|
|
||||||
```json
|
|
||||||
{ "message": "Failed to set return date" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Status codes
|
|
||||||
|
|
||||||
- `200 OK` – return date set and items marked as returned
|
|
||||||
- `401 Unauthorized` – invalid / missing key
|
|
||||||
- `500 Internal Server Error` – invalid loan, missing items, or DB error / `success: false`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Response Shapes
|
## Error handling
|
||||||
|
|
||||||
**Success – list (authenticated items):**
|
- 401 Unauthorized: Missing or invalid API key
|
||||||
|
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
|
||||||
|
- 404 Not Found: Loan not found
|
||||||
|
- 500 Internal Server Error: Database or server error
|
||||||
|
|
||||||
```json
|
---
|
||||||
{ "data": [ /* array of rows */ ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success – single loan:**
|
If you have questions or want to collaborate, please reach out!
|
||||||
|
|
||||||
```json
|
|
||||||
{ "data": { /* selected loan fields */ } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success – mutations (current code):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "data": null }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "message": "Failed to fetch items" }
|
|
||||||
{ "message": "Failed to update item state" }
|
|
||||||
{ "message": "Invalid state value" }
|
|
||||||
{ "message": "Loan not found" }
|
|
||||||
{ "message": "Failed to set return date" }
|
|
||||||
{ "message": "Failed to set take date" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**HTTP Status Codes:**
|
|
||||||
|
|
||||||
- `200 OK` – operation succeeded
|
|
||||||
- `400 Bad Request` – invalid `state` parameter
|
|
||||||
- `401 Unauthorized` – invalid/missing API key
|
|
||||||
- `404 Not Found` – loan not found
|
|
||||||
- `500 Internal Server Error` – database / server failure or `success: false` from DB layer
|
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
FROM node:18 as builder
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
EXPOSE 8001
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
CMD ["npm", "run", "dev"]
|
||||||
COPY --from=builder /app/dist .
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
|
||||||
expires 1y;
|
|
||||||
access_log off;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
FrontendV2/package-lock.json
generated
2
FrontendV2/package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"@chakra-ui/react": "^3.28.0",
|
"@chakra-ui/react": "^3.28.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"jotai": "^2.15.0",
|
"jotai": "^2.15.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"@chakra-ui/react": "^3.28.0",
|
"@chakra-ui/react": "^3.28.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"jotai": "^2.15.0",
|
"jotai": "^2.15.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
|||||||
@@ -1 +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>
|
<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: 420 B After Width: | Height: | Size: 1.5 KiB |
@@ -13,11 +13,12 @@ import { MyLoansPage } from "./pages/MyLoansPage";
|
|||||||
import Landingpage from "./pages/Landingpage";
|
import Landingpage from "./pages/Landingpage";
|
||||||
import { changeLanguage } from "i18next";
|
import { changeLanguage } from "i18next";
|
||||||
import { Box, Flex } from "@chakra-ui/react";
|
import { Box, Flex } from "@chakra-ui/react";
|
||||||
import { Footer } from "./components/footer/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [user, setUser] = useState<User | undefined>(undefined);
|
const [user, setUser] = useState<User | undefined>(undefined);
|
||||||
@@ -27,7 +28,7 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Cookies.get("token")) {
|
if (Cookies.get("token")) {
|
||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
const response = await fetch(`${API_BASE}/verify`, {
|
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
@@ -64,7 +65,6 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Flex direction="column" minH="100vh">
|
<Flex direction="column" minH="100vh">
|
||||||
<Box as="main" flex="1">
|
<Box as="main" flex="1">
|
||||||
<UserContext.Provider value={user}>
|
<UserContext.Provider value={user}>
|
||||||
@@ -83,7 +83,6 @@ function App() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Flex>
|
</Flex>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Box } from "@chakra-ui/react";
|
import { Box } from "@chakra-ui/react";
|
||||||
import { useVersionInfoQuery } from "./versionInfo.query";
|
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const { data: info } = useVersionInfoQuery();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="footer"
|
as="footer"
|
||||||
@@ -16,8 +13,7 @@ export const Footer = () => {
|
|||||||
>
|
>
|
||||||
Made with ❤️ by Theis Gaedigk - Year 2019 at MCS-Bochum
|
Made with ❤️ by Theis Gaedigk - Year 2019 at MCS-Bochum
|
||||||
<br />
|
<br />
|
||||||
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
|
v2.0
|
||||||
Backend-Version: {info ? info["backend-info"].version : "N/A"}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,11 @@ import { useUserContext } from "@/states/Context";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MyAlert from "./myChakra/MyAlert";
|
import MyAlert from "./myChakra/MyAlert";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -64,7 +68,7 @@ export const Header = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/users/change-password`, {
|
const response = await fetch(`${API_BASE}/api/changePassword`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
export const useVersionInfoQuery = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["versionInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${API_BASE}/`, {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"Failed to fetch version info (versionInfo.query.ts): ",
|
|
||||||
response.statusText
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
"backend-info": {
|
|
||||||
version: "N/A",
|
|
||||||
},
|
|
||||||
"frontend-info": {
|
|
||||||
version: "N/A",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export const API_BASE =
|
|
||||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
|
||||||
import.meta.env.VITE_BACKEND_URL ||
|
|
||||||
"http://localhost:8002";
|
|
||||||
@@ -5,7 +5,7 @@ import "./index.css";
|
|||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
|
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
|
||||||
|
|
||||||
// Prevent unused variable tree shaking
|
// code below is to avoid linter error for unused import
|
||||||
let i18nUnused = i18n;
|
let i18nUnused = i18n;
|
||||||
console.log(i18nUnused);
|
console.log(i18nUnused);
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
VStack,
|
VStack,
|
||||||
Table,
|
Table,
|
||||||
InputGroup,
|
|
||||||
Span,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { getBorrowableItems } from "@/utils/Fetcher";
|
import { getBorrowableItems } from "@/utils/Fetcher";
|
||||||
@@ -33,9 +31,6 @@ export const HomePage = () => {
|
|||||||
const [isLoadingA, setIsLoadingA] = useState(false);
|
const [isLoadingA, setIsLoadingA] = useState(false);
|
||||||
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
||||||
|
|
||||||
const MAX_CHARACTERS = 500;
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
|
|
||||||
// Error handling states
|
// Error handling states
|
||||||
const [isMsg, setIsMsg] = useState(false);
|
const [isMsg, setIsMsg] = useState(false);
|
||||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
||||||
@@ -61,7 +56,6 @@ export const HomePage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Stack as="main">
|
<Stack as="main">
|
||||||
<Text>{t("timezone-info")}</Text>
|
|
||||||
<label htmlFor="startDate">
|
<label htmlFor="startDate">
|
||||||
<Text>{t("start-date")}</Text>
|
<Text>{t("start-date")}</Text>
|
||||||
</label>
|
</label>
|
||||||
@@ -141,29 +135,13 @@ export const HomePage = () => {
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
<InputGroup
|
|
||||||
endElement={
|
|
||||||
<Span color="fg.muted" textStyle="xs">
|
|
||||||
{note.length} / {MAX_CHARACTERS}
|
|
||||||
</Span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder={t("optional-note")}
|
|
||||||
value={note}
|
|
||||||
maxLength={MAX_CHARACTERS}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNote(e.currentTarget.value.slice(0, MAX_CHARACTERS));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Table.ScrollArea>
|
</Table.ScrollArea>
|
||||||
)}
|
)}
|
||||||
{selectedItems.length >= 1 && (
|
{selectedItems.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
createLoan(selectedItems, startDate, endDate, note).then((response) => {
|
createLoan(selectedItems, startDate, endDate).then((response) => {
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
setMsgStatus("error");
|
setMsgStatus("error");
|
||||||
setMsgTitle(response.title || t("error"));
|
setMsgTitle(response.title || t("error"));
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
import { Lock, LockOpen } from "lucide-react";
|
import { Lock, LockOpen } from "lucide-react";
|
||||||
import MyAlert from "@/components/myChakra/MyAlert";
|
import MyAlert from "@/components/myChakra/MyAlert";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
export const formatDateTime = (value: string | null | undefined) => {
|
export const formatDateTime = (value: string | null | undefined) => {
|
||||||
if (!value) return "N/A";
|
if (!value) return "N/A";
|
||||||
@@ -24,6 +22,11 @@ export const formatDateTime = (value: string | null | undefined) => {
|
|||||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
type Loan = {
|
type Loan = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -40,8 +43,6 @@ type Device = {
|
|||||||
can_borrow_role: string;
|
can_borrow_role: string;
|
||||||
inSafe: number;
|
inSafe: number;
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
last_borrowed_person: string | null;
|
|
||||||
currently_borrowing: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Landingpage: React.FC = () => {
|
const Landingpage: React.FC = () => {
|
||||||
@@ -71,12 +72,7 @@ const Landingpage: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const loanRes = await fetch(`${API_BASE}/api/loans/all-loans`, {
|
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const loanData = await loanRes.json();
|
const loanData = await loanRes.json();
|
||||||
if (Array.isArray(loanData)) {
|
if (Array.isArray(loanData)) {
|
||||||
setLoans(loanData);
|
setLoans(loanData);
|
||||||
@@ -88,12 +84,7 @@ const Landingpage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`, {
|
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const deviceData = await deviceRes.json();
|
const deviceData = await deviceRes.json();
|
||||||
if (Array.isArray(deviceData)) {
|
if (Array.isArray(deviceData)) {
|
||||||
setDevices(deviceData);
|
setDevices(deviceData);
|
||||||
@@ -213,14 +204,6 @@ const Landingpage: React.FC = () => {
|
|||||||
<Text>
|
<Text>
|
||||||
{t("rent-role")}: {device.can_borrow_role}
|
{t("rent-role")}: {device.can_borrow_role}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
|
||||||
{t("last-borrowed-person")}:{" "}
|
|
||||||
{device.last_borrowed_person || "N/A"}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
{t("currently-borrowed-by")}:{" "}
|
|
||||||
{device.currently_borrowing || "N/A"}
|
|
||||||
</Text>
|
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import Cookies from "js-cookie";
|
|||||||
import { Navigate, useNavigate } from "react-router-dom";
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Footer } from "@/components/footer/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -20,12 +24,12 @@ export const LoginPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden
|
window.location.reload(); // Mit Tobais besprechen, ob das so bleiben soll
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, navigate]);
|
}, [isLoggedIn, navigate]);
|
||||||
|
|
||||||
const loginFnc = async (username: string, password: string) => {
|
const loginFnc = async (username: string, password: string) => {
|
||||||
const response = await fetch(`${API_BASE}/api/users/login`, {
|
const response = await fetch(`${API_BASE}/api/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const MyLoansPage = () => {
|
export const MyLoansPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -43,7 +47,7 @@ export const MyLoansPage = () => {
|
|||||||
const fetchLoans = async () => {
|
const fetchLoans = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await fetch(`${API_BASE}/api/loans/loans`, {
|
const res = await fetch(`${API_BASE}/api/userLoans`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
@@ -75,7 +79,7 @@ export const MyLoansPage = () => {
|
|||||||
|
|
||||||
const deleteLoan = async (loanId: number) => {
|
const deleteLoan = async (loanId: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/loans/delete-loan/${loanId}`, {
|
const res = await fetch(`${API_BASE}/api/SETdeleteLoan/${loanId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
@@ -147,8 +151,6 @@ export const MyLoansPage = () => {
|
|||||||
<Table.Column style={{ width: "14%" }} />
|
<Table.Column style={{ width: "14%" }} />
|
||||||
{/* Rückgabedatum */}
|
{/* Rückgabedatum */}
|
||||||
<Table.Column style={{ width: "14%" }} />
|
<Table.Column style={{ width: "14%" }} />
|
||||||
{/* Notiz */}
|
|
||||||
<Table.Column style={{ width: "14%" }} />
|
|
||||||
{/* Aktionen */}
|
{/* Aktionen */}
|
||||||
<Table.Column style={{ width: "8%" }} />
|
<Table.Column style={{ width: "8%" }} />
|
||||||
</Table.ColumnGroup>
|
</Table.ColumnGroup>
|
||||||
@@ -160,7 +162,6 @@ export const MyLoansPage = () => {
|
|||||||
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t("note")}</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>{t("actions")}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t("actions")}</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
@@ -181,7 +182,6 @@ export const MyLoansPage = () => {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
|
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
|
||||||
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
|
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
|
||||||
<Table.Cell>{loan.note}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Dialog.Root role="alertdialog">
|
<Dialog.Root role="alertdialog">
|
||||||
<Dialog.Trigger asChild>
|
<Dialog.Trigger asChild>
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
|
||||||
interface Meta {
|
|
||||||
"backend-info": {
|
|
||||||
version: String;
|
|
||||||
};
|
|
||||||
"frontend-info": {
|
|
||||||
version: String;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testAtom = atom<number>(0);
|
export const testAtom = atom<number>(0);
|
||||||
export const setIsLoggedInAtom = atom<boolean>(false);
|
export const setIsLoggedInAtom = atom<boolean>(false);
|
||||||
export const triggerLogoutAtom = atom<boolean>(false);
|
export const triggerLogoutAtom = atom<boolean>(false);
|
||||||
export const borrowAbleItemsAtom = atom<any[]>([]);
|
export const borrowAbleItemsAtom = atom<any[]>([]);
|
||||||
export const infoAtom = atom<Meta | undefined>(undefined);
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const getBorrowableItems = async (
|
export const getBorrowableItems = async (
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string
|
endDate: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
|
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
@@ -27,6 +30,7 @@ export const getBorrowableItems = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
return {
|
return {
|
||||||
data: data,
|
data: data,
|
||||||
status: "success",
|
status: "success",
|
||||||
@@ -47,16 +51,15 @@ export const getBorrowableItems = async (
|
|||||||
export const createLoan = async (
|
export const createLoan = async (
|
||||||
itemIds: number[],
|
itemIds: number[],
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string
|
||||||
note: string | null
|
|
||||||
) => {
|
) => {
|
||||||
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
|
const response = await fetch(`${API_BASE}/api/createLoan`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ items: itemIds, startDate, endDate, note }),
|
body: JSON.stringify({ items: itemIds, startDate, endDate }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -59,7 +59,5 @@
|
|||||||
"sure-delete-loan-1": " Ausleihcode wirklich löschen?",
|
"sure-delete-loan-1": " Ausleihcode wirklich löschen?",
|
||||||
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
|
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"change-language": "Sprache ändern",
|
"change-language": "Sprache ändern"
|
||||||
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
|
|
||||||
"optional-note": "Optionale Notiz"
|
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,5 @@
|
|||||||
"sure-delete-loan-1": " loan code?",
|
"sure-delete-loan-1": " loan code?",
|
||||||
"sure-delete-loan-2": "It will remain visible to the admin.",
|
"sure-delete-loan-2": "It will remain visible to the admin.",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"change-language": "Change language",
|
"change-language": "Change language"
|
||||||
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
|
|
||||||
"optional-note": "Optional note"
|
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
FROM node:18 as builder
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
EXPOSE 8003
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
CMD ["npm", "run", "dev"]
|
||||||
COPY --from=builder /app/dist .
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
|
||||||
expires 1y;
|
|
||||||
access_log off;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,11 @@ import { useEffect } from "react";
|
|||||||
import Dashboard from "./Dashboard";
|
import Dashboard from "./Dashboard";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
const Layout: React.FC = () => {
|
const Layout: React.FC = () => {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -11,15 +15,12 @@ const Layout: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Cookies.get("token")) {
|
if (Cookies.get("token")) {
|
||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||||
`${API_BASE}/api/admin/user-mgmt/verify-token`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
viewAusleihen: () => void;
|
viewAusleihen: () => void;
|
||||||
@@ -17,22 +15,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
viewUser,
|
viewUser,
|
||||||
viewAPI,
|
viewAPI,
|
||||||
}) => {
|
}) => {
|
||||||
const [info, setInfo] = useState<any>(null);
|
|
||||||
|
|
||||||
const fetchInfo = async () => {
|
|
||||||
const response = await fetch(`${API_BASE}/`);
|
|
||||||
const data = await response.json();
|
|
||||||
setInfo(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="aside"
|
as="aside"
|
||||||
w="180px"
|
w="260px"
|
||||||
minH="100vh"
|
minH="100vh"
|
||||||
bg="gray.800"
|
bg="gray.800"
|
||||||
color="gray.100"
|
color="gray.100"
|
||||||
@@ -86,33 +72,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
||||||
<Text mb={2}>© Made with ❤️ by Theis Gaedigk</Text>
|
<Text>© Made with ❤️ by Theis Gaedigk</Text>
|
||||||
{info ? (
|
|
||||||
<Flex gap={2} wrap="wrap">
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
px={2}
|
|
||||||
py={0.5}
|
|
||||||
rounded="full"
|
|
||||||
bg="gray.700"
|
|
||||||
color="gray.200"
|
|
||||||
>
|
|
||||||
Panel {info?.["admin-panel-info"]?.version ?? "—"}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
px={2}
|
|
||||||
py={0.5}
|
|
||||||
rounded="full"
|
|
||||||
bg="gray.700"
|
|
||||||
color="gray.200"
|
|
||||||
>
|
|
||||||
Backend {info?.["backend-info"]?.version ?? "—"}
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Text color="gray.600">Lade Versionsinfos…</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ import { useState, useEffect } from "react";
|
|||||||
import { deleteAPKey } from "@/utils/userActions";
|
import { deleteAPKey } from "@/utils/userActions";
|
||||||
import AddAPIKey from "./AddAPIKey";
|
import AddAPIKey from "./AddAPIKey";
|
||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
type Items = {
|
type Items = {
|
||||||
id: number;
|
id: number;
|
||||||
api_key: string;
|
apiKey: string;
|
||||||
entry_name: string;
|
user: string;
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
last_used_at: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const APIKeyTable: React.FC = () => {
|
const APIKeyTable: React.FC = () => {
|
||||||
@@ -53,17 +56,13 @@ const APIKeyTable: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/apiKeys`, {
|
||||||
`${API_BASE}/api/admin/api-data/get-api-keys`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data);
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError("error", "Failed to fetch items", "There is an error");
|
setError("error", "Failed to fetch items", "There is an error");
|
||||||
@@ -150,55 +149,39 @@ const APIKeyTable: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table.Root
|
<Table.Root size="sm" striped>
|
||||||
size="sm"
|
|
||||||
striped
|
|
||||||
w="100%"
|
|
||||||
// table-layout: auto => Spaltenbreite nach Content; volle Breite nutzen
|
|
||||||
style={{ tableLayout: "auto" }}
|
|
||||||
>
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>#</strong>
|
<strong>#</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>API Key</strong>
|
<strong>API Key</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Name</strong>
|
<strong>Benutzer</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag erstellt am</strong>
|
<strong>Eintrag erstellt am</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Zuletzt benutzt am</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{items.map((item) => (
|
{items.map((apiKey) => (
|
||||||
<Table.Row key={item.id}>
|
<Table.Row key={apiKey.id}>
|
||||||
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
|
<Table.Cell>{apiKey.id}</Table.Cell>
|
||||||
<Table.Cell fontFamily="mono">{item.api_key}</Table.Cell>
|
<Table.Cell>{apiKey.apiKey}</Table.Cell>
|
||||||
<Table.Cell>{item.entry_name}</Table.Cell>
|
<Table.Cell>{apiKey.user}</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
|
||||||
{formatDateTime(item.entry_created_at)}
|
<Table.Cell>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
{!item.last_used_at
|
|
||||||
? "Nie benutzt"
|
|
||||||
: formatDateTime(item.last_used_at)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
deleteAPKey(item.id).then((response) => {
|
deleteAPKey(apiKey.id).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setItems(items.filter((i) => i.id !== item.id));
|
setItems(items.filter((i) => i.id !== apiKey.id));
|
||||||
setError(
|
setError(
|
||||||
"success",
|
"success",
|
||||||
"Gegenstand gelöscht",
|
"Gegenstand gelöscht",
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
InputGroup,
|
|
||||||
Span,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { createAPIentry } from "@/utils/userActions";
|
import { createAPIentry } from "@/utils/userActions";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type AddAPIKeyProps = {
|
type AddAPIKeyProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,8 +12,6 @@ type AddAPIKeyProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
||||||
const [value, setValue] = useState("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<Card.Root maxW="sm">
|
<Card.Root maxW="sm">
|
||||||
@@ -34,26 +23,13 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Stack gap="4" w="full">
|
<Stack gap="4" w="full">
|
||||||
<InputGroup
|
|
||||||
endElement={
|
|
||||||
<Span color="fg.muted" textStyle="xs">
|
|
||||||
{value.length} / {15}
|
|
||||||
</Span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Er muss 15 Zeichen lang sein"
|
|
||||||
value={value}
|
|
||||||
id="apiKey"
|
|
||||||
maxLength={15}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.currentTarget.value.slice(0, 15));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Name</Field.Label>
|
<Field.Label>API key</Field.Label>
|
||||||
<Input id="name" type="text" />
|
<Input type="number" id="apiKey" />
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Benutzer</Field.Label>
|
||||||
|
<Input id="user" type="text" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
@@ -68,14 +44,14 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
|||||||
(
|
(
|
||||||
document.getElementById("apiKey") as HTMLInputElement
|
document.getElementById("apiKey") as HTMLInputElement
|
||||||
)?.value.trim() || "";
|
)?.value.trim() || "";
|
||||||
const name =
|
const user =
|
||||||
(
|
(
|
||||||
document.getElementById("name") as HTMLInputElement
|
document.getElementById("user") as HTMLInputElement
|
||||||
)?.value.trim() || "";
|
)?.value.trim() || "";
|
||||||
|
|
||||||
if (!apiKey || !name) return;
|
if (!apiKey || !user) return;
|
||||||
|
|
||||||
const res = await createAPIentry(apiKey, name);
|
const res = await createAPIentry(apiKey, user);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
alert(
|
alert(
|
||||||
"success",
|
"success",
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Checkbox,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { createUser } from "@/utils/userActions";
|
import { createUser } from "@/utils/userActions";
|
||||||
|
|
||||||
type AddFormProps = {
|
type AddFormProps = {
|
||||||
@@ -20,71 +12,37 @@ type AddFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
||||||
const [admin, setAdmin] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card.Root maxW="sm">
|
<Card.Root maxW="sm">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Neuen Nutzer erstellen</Card.Title>
|
<Card.Title>Neuen Nutzer erstellen</Card.Title>
|
||||||
<Card.Description>
|
<Card.Description>
|
||||||
Füllen Sie das folgende Formular aus, um einen Nutzer zu
|
Füllen Sie das folgende Formular aus, um einen Nutzer zu erstellen.
|
||||||
erstellen.
|
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Stack gap="4" w="full">
|
<Stack gap="4" w="full">
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Benutzername</Field.Label>
|
<Field.Label>Username</Field.Label>
|
||||||
<Input id="username" />
|
<Input id="username" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Passwort</Field.Label>
|
<Field.Label>Password</Field.Label>
|
||||||
<Input id="password" type="password" />
|
<Input id="password" type="password" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Field.Root>
|
<Field.Root>
|
||||||
<Field.Label>Vorname</Field.Label>
|
<Field.Label>Role</Field.Label>
|
||||||
<Input id="firstname" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Nachname</Field.Label>
|
|
||||||
<Input id="lastname" />
|
|
||||||
</Field.Root>
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>E-Mail</Field.Label>
|
|
||||||
<Input id="email" type="email" />
|
|
||||||
</Field.Root>
|
|
||||||
|
|
||||||
{/* Kontrollierte Checkbox */}
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={admin}
|
|
||||||
onCheckedChange={(e: any) => setAdmin(Boolean(e?.checked ?? e))}
|
|
||||||
>
|
|
||||||
<Checkbox.HiddenInput />
|
|
||||||
<Checkbox.Control />
|
|
||||||
<Checkbox.Label>Admin</Checkbox.Label>
|
|
||||||
</Checkbox.Root>
|
|
||||||
|
|
||||||
<Field.Root>
|
|
||||||
<Field.Label>Rolle</Field.Label>
|
|
||||||
<Input id="role" type="number" />
|
<Input id="role" type="number" />
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
<Card.Footer justifyContent="flex-end">
|
<Card.Footer justifyContent="flex-end">
|
||||||
<Text>Der Benutzername kann nicht mehr geändert werden.</Text>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
type="submit"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const username =
|
const username =
|
||||||
(
|
(
|
||||||
@@ -96,30 +54,10 @@ const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
|||||||
const role = Number(
|
const role = Number(
|
||||||
(document.getElementById("role") as HTMLInputElement)?.value
|
(document.getElementById("role") as HTMLInputElement)?.value
|
||||||
);
|
);
|
||||||
const firstname =
|
|
||||||
(
|
|
||||||
document.getElementById("firstname") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
const lastname =
|
|
||||||
(
|
|
||||||
document.getElementById("lastname") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
const email =
|
|
||||||
(
|
|
||||||
document.getElementById("email") as HTMLInputElement
|
|
||||||
)?.value.trim() || "";
|
|
||||||
|
|
||||||
// admin kommt jetzt zuverlässig aus dem State
|
if (!username || !password || Number.isNaN(role)) return;
|
||||||
const res = await createUser(
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
firstname,
|
|
||||||
lastname,
|
|
||||||
email,
|
|
||||||
admin
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const res = await createUser(username, role, password);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
alert(
|
alert(
|
||||||
"success",
|
"success",
|
||||||
@@ -141,7 +79,6 @@ const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
Icon,
|
Icon,
|
||||||
Input,
|
Input,
|
||||||
Box, // added
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Tooltip } from "@/components/ui/tooltip";
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
import MyAlert from "./myChakra/MyAlert";
|
import MyAlert from "./myChakra/MyAlert";
|
||||||
@@ -31,17 +30,18 @@ import {
|
|||||||
} from "@/utils/userActions";
|
} from "@/utils/userActions";
|
||||||
import AddItemForm from "./AddItemForm";
|
import AddItemForm from "./AddItemForm";
|
||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
type Items = {
|
type Items = {
|
||||||
id: number;
|
id: number;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
can_borrow_role: string;
|
can_borrow_role: string;
|
||||||
in_safe: boolean;
|
inSafe: boolean;
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
entry_updated_at: string;
|
|
||||||
last_borrowed_person: string | null;
|
|
||||||
currently_borrowing: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ItemTable: React.FC = () => {
|
const ItemTable: React.FC = () => {
|
||||||
@@ -82,15 +82,12 @@ const ItemTable: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/allItems`, {
|
||||||
`${API_BASE}/api/admin/item-data/all-items`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -178,15 +175,7 @@ const ItemTable: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* make table content-sized with horizontal scroll if needed */}
|
<Table.Root size="sm" striped>
|
||||||
<Box overflowX="auto">
|
|
||||||
<Table.Root
|
|
||||||
size="sm"
|
|
||||||
striped
|
|
||||||
tableLayout="auto"
|
|
||||||
w="max-content"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
>
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
@@ -204,15 +193,6 @@ const ItemTable: React.FC = () => {
|
|||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag erstellt am</strong>
|
<strong>Eintrag erstellt am</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Eintrag aktualisiert am</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Letzte ausleihende Person</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Derzeit ausgeliehen von</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
@@ -224,8 +204,6 @@ const ItemTable: React.FC = () => {
|
|||||||
<Table.Cell>{item.id}</Table.Cell>
|
<Table.Cell>{item.id}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleItemNameChange(item.id, e.target.value)
|
handleItemNameChange(item.id, e.target.value)
|
||||||
}
|
}
|
||||||
@@ -234,8 +212,6 @@ const ItemTable: React.FC = () => {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
|
||||||
w="max-content"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCanBorrowRoleChange(item.id, e.target.value)
|
handleCanBorrowRoleChange(item.id, e.target.value)
|
||||||
}
|
}
|
||||||
@@ -253,34 +229,31 @@ const ItemTable: React.FC = () => {
|
|||||||
py={1}
|
py={1}
|
||||||
gap={2}
|
gap={2}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color={item.in_safe ? "green.600" : "red.600"}
|
color={item.inSafe ? "green.600" : "red.600"}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={item.in_safe ? "green.300" : "red.300"}
|
borderColor={item.inSafe ? "green.300" : "red.300"}
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: item.in_safe ? "green.50" : "red.50",
|
bg: item.inSafe ? "green.50" : "red.50",
|
||||||
borderColor: item.in_safe ? "green.400" : "red.400",
|
borderColor: item.inSafe ? "green.400" : "red.400",
|
||||||
transform: "translateY(-1px)",
|
transform: "translateY(-1px)",
|
||||||
shadow: "sm",
|
shadow: "sm",
|
||||||
}}
|
}}
|
||||||
_active={{ transform: "translateY(0)" }}
|
_active={{ transform: "translateY(0)" }}
|
||||||
aria-label={
|
aria-label={
|
||||||
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
|
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
as={item.in_safe ? CheckCircle2 : XCircle}
|
as={item.inSafe ? CheckCircle2 : XCircle}
|
||||||
boxSize={3.5}
|
boxSize={3.5}
|
||||||
mr={2}
|
mr={2}
|
||||||
/>
|
/>
|
||||||
<Text as="span" fontSize="xs" fontWeight="semibold">
|
<Text as="span" fontSize="xs" fontWeight="semibold">
|
||||||
{item.in_safe ? "Yes" : "No"}
|
{item.inSafe ? "Yes" : "No"}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
|
|
||||||
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
|
|
||||||
<Table.Cell>{item.currently_borrowing}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -332,7 +305,6 @@ const ItemTable: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import MyAlert from "./myChakra/MyAlert";
|
|||||||
import { formatDateTime } from "@/utils/userFuncs";
|
import { formatDateTime } from "@/utils/userFuncs";
|
||||||
import { Trash2, RefreshCcwDot } from "lucide-react";
|
import { Trash2, RefreshCcwDot } from "lucide-react";
|
||||||
import { deleteLoan } from "@/utils/userActions";
|
import { deleteLoan } from "@/utils/userActions";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
const LoanTable: React.FC = () => {
|
const LoanTable: React.FC = () => {
|
||||||
const [items, setItems] = useState<Loan[]>([]);
|
const [items, setItems] = useState<Loan[]>([]);
|
||||||
@@ -51,22 +55,18 @@ const LoanTable: React.FC = () => {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
loaned_items_name: string[];
|
loaned_items_name: string[];
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
note: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/allLoans`, {
|
||||||
`${API_BASE}/api/admin/loan-data/all-loans`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,9 +161,6 @@ const LoanTable: React.FC = () => {
|
|||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Ausgeliehene Artikel</strong>
|
<strong>Ausgeliehene Artikel</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Notiz</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
@@ -183,7 +180,6 @@ const LoanTable: React.FC = () => {
|
|||||||
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
|
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
|
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
|
||||||
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
|
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
|
||||||
<Table.Cell>{item.note}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
IconButton,
|
IconButton,
|
||||||
Heading,
|
Heading,
|
||||||
Switch, // neu
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Tooltip } from "@/components/ui/tooltip";
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
import { fetchUserData } from "@/utils/fetcher";
|
import { fetchUserData } from "@/utils/fetcher";
|
||||||
@@ -24,13 +23,9 @@ import ChangePWform from "./ChangePWform";
|
|||||||
type User = {
|
type User = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
password: string;
|
||||||
last_name: string;
|
role: string;
|
||||||
email: string;
|
|
||||||
is_admin: boolean;
|
|
||||||
role: number;
|
|
||||||
entry_created_at: string;
|
entry_created_at: string;
|
||||||
entry_updated_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserTable: React.FC = () => {
|
const UserTable: React.FC = () => {
|
||||||
@@ -57,20 +52,10 @@ const UserTable: React.FC = () => {
|
|||||||
setIsError(true);
|
setIsError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (userId: number, field: string, value: any) => {
|
const handleInputChange = (userId: number, field: string, value: string) => {
|
||||||
setUsers((prevUsers) =>
|
setUsers((prevUsers) =>
|
||||||
prevUsers.map((user) =>
|
prevUsers.map((user) =>
|
||||||
user.id === userId
|
user.id === userId ? { ...user, [field]: value } : user
|
||||||
? {
|
|
||||||
...user,
|
|
||||||
[field]:
|
|
||||||
field === "role"
|
|
||||||
? Number(value)
|
|
||||||
: field === "is_admin"
|
|
||||||
? value === true || value === "true" || value === 1
|
|
||||||
: value,
|
|
||||||
}
|
|
||||||
: user
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -85,7 +70,7 @@ const UserTable: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchUserData();
|
const data = await fetchUserData();
|
||||||
console.log(data);
|
console.log("user api response", data);
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setUsers(data);
|
setUsers(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -195,45 +180,25 @@ const UserTable: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Table.Root
|
<Table.Root size="sm" striped>
|
||||||
size="sm"
|
|
||||||
striped
|
|
||||||
w="100%"
|
|
||||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
|
||||||
>
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>#</strong>
|
<strong>#</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Benutzername</strong>
|
<strong>Benutzername</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Vorname</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>Nachname</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader>
|
|
||||||
<strong>E-Mail</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Admin</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
|
||||||
<strong>Passwort ändern</strong>
|
<strong>Passwort ändern</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Rolle</strong>
|
<strong>Rolle</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag erstellt am</strong>
|
<strong>Eintrag erstellt am</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader whiteSpace="nowrap">
|
<Table.ColumnHeader>
|
||||||
<strong>Eintrag aktualisiert am</strong>
|
|
||||||
</Table.ColumnHeader>
|
|
||||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
|
||||||
<strong>Aktionen</strong>
|
<strong>Aktionen</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -241,86 +206,37 @@ const UserTable: React.FC = () => {
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<Table.Row key={user.id}>
|
<Table.Row key={user.id}>
|
||||||
<Table.Cell whiteSpace="nowrap">{user.id}</Table.Cell>
|
<Table.Cell>{user.id}</Table.Cell>
|
||||||
<Table.Cell>{user.username}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
|
||||||
value={user.first_name ?? ""}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleInputChange(user.id, "first_name", e.target.value)
|
handleInputChange(user.id, "username", e.target.value)
|
||||||
}
|
}
|
||||||
|
value={user.username}
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Input
|
<Button onClick={() => handlePasswordChange(user.username)}>
|
||||||
size="sm"
|
|
||||||
value={user.last_name ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange(user.id, "last_name", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
size="sm"
|
|
||||||
value={user.email ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange(user.id, "email", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Switch.Root
|
|
||||||
size="sm"
|
|
||||||
checked={!!user.is_admin}
|
|
||||||
onCheckedChange={(d) =>
|
|
||||||
handleInputChange(user.id, "is_admin", d.checked)
|
|
||||||
}
|
|
||||||
aria-label="Adminrechte umschalten"
|
|
||||||
>
|
|
||||||
<Switch.Control>
|
|
||||||
<Switch.Thumb />
|
|
||||||
</Switch.Control>
|
|
||||||
<Switch.HiddenInput />
|
|
||||||
</Switch.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePasswordChange(user.username)}
|
|
||||||
>
|
|
||||||
Passwort ändern
|
Passwort ändern
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
size="sm"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleInputChange(user.id, "role", e.target.value)
|
handleInputChange(user.id, "role", e.target.value)
|
||||||
}
|
}
|
||||||
value={user.role}
|
value={user.role}
|
||||||
width="70px"
|
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell>
|
||||||
{formatDateTime(user.entry_created_at)}
|
<Table.Cell>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
{formatDateTime(user.entry_updated_at)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell whiteSpace="nowrap">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleEdit(
|
handleEdit(
|
||||||
user.id,
|
user.id,
|
||||||
user.first_name,
|
user.username,
|
||||||
user.last_name,
|
user.role,
|
||||||
user.email,
|
|
||||||
user.is_admin,
|
|
||||||
Number(user.role)
|
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const API_BASE =
|
|
||||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
|
||||||
import.meta.env.VITE_BACKEND_URL ||
|
|
||||||
"http://localhost:8002";
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const fetchUserData = async () => {
|
export const fetchUserData = async () => {
|
||||||
const response = await fetch(`${API_BASE}/api/admin/user-data/users`, {
|
const response = await fetch(`${API_BASE}/api/allUsers`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export type LoginSuccess = { success: true };
|
export type LoginSuccess = { success: true };
|
||||||
export type LoginFailure = {
|
export type LoginFailure = {
|
||||||
@@ -14,20 +18,12 @@ export const loginFunc = async (
|
|||||||
password: string
|
password: string
|
||||||
): Promise<LoginResult> => {
|
): Promise<LoginResult> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/admin/user-mgmt/login`, {
|
const response = await fetch(`${API_BASE}/api/loginAdmin`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Login failed!",
|
|
||||||
description: "You are not an admin user.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -43,7 +39,6 @@ export const loginFunc = async (
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error logging in:", error);
|
console.error("Error logging in:", error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Login failed!",
|
message: "Login failed!",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { API_BASE } from "@/config/api.config";
|
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||||
|
import.meta.env.VITE_BACKEND_URL ||
|
||||||
|
"http://localhost:8002";
|
||||||
|
|
||||||
export const handleDelete = async (userId: number) => {
|
export const handleDelete = async (userId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/user-data/delete-user/${userId}`,
|
`${API_BASE}/api/deleteUser/${userId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -24,28 +28,19 @@ export const handleDelete = async (userId: number) => {
|
|||||||
|
|
||||||
export const handleEdit = async (
|
export const handleEdit = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
first_name: string,
|
username: string,
|
||||||
last_name: string,
|
role: string
|
||||||
email: string,
|
|
||||||
is_admin: boolean,
|
|
||||||
role: number
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/user-data/edit-user/${userId}`,
|
`${API_BASE}/api/editUser/${userId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, role }),
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -61,32 +56,17 @@ export const handleEdit = async (
|
|||||||
export const createUser = async (
|
export const createUser = async (
|
||||||
username: string,
|
username: string,
|
||||||
role: number,
|
role: number,
|
||||||
password: string,
|
password: string
|
||||||
first_name: string,
|
|
||||||
last_name: string,
|
|
||||||
email: string,
|
|
||||||
isAdmin: boolean
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createUser`, {
|
||||||
`${API_BASE}/api/admin/user-data/create-user`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ username, role, password }),
|
||||||
username,
|
});
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to create user");
|
throw new Error("Failed to create user");
|
||||||
}
|
}
|
||||||
@@ -99,17 +79,14 @@ export const createUser = async (
|
|||||||
|
|
||||||
export const changePW = async (newPassword: string, username: string) => {
|
export const changePW = async (newPassword: string, username: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/changePWadmin`, {
|
||||||
`${API_BASE}/api/admin/user-data/change-password`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password: newPassword }),
|
body: JSON.stringify({ newPassword, username }),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to change password");
|
throw new Error("Failed to change password");
|
||||||
}
|
}
|
||||||
@@ -123,7 +100,7 @@ export const changePW = async (newPassword: string, username: string) => {
|
|||||||
export const deleteLoan = async (loanId: number) => {
|
export const deleteLoan = async (loanId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/loan-data/delete-loan/${loanId}`,
|
`${API_BASE}/api/deleteLoan/${loanId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -144,7 +121,7 @@ export const deleteLoan = async (loanId: number) => {
|
|||||||
export const deleteItem = async (itemId: number) => {
|
export const deleteItem = async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/item-data/delete-item/${itemId}`,
|
`${API_BASE}/api/deleteItem/${itemId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -167,17 +144,14 @@ export const createItem = async (
|
|||||||
can_borrow_role: number
|
can_borrow_role: number
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createItem`, {
|
||||||
`${API_BASE}/api/admin/item-data/create-item`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ item_name, can_borrow_role }),
|
body: JSON.stringify({ item_name, can_borrow_role }),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -198,17 +172,14 @@ export const handleEditItems = async (
|
|||||||
can_borrow_role: string
|
can_borrow_role: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/updateItemByID`, {
|
||||||
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ item_name, can_borrow_role }),
|
body: JSON.stringify({ itemId, item_name, can_borrow_role }),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to edit item");
|
throw new Error("Failed to edit item");
|
||||||
}
|
}
|
||||||
@@ -222,9 +193,9 @@ export const handleEditItems = async (
|
|||||||
export const changeSafeState = async (itemId: number) => {
|
export const changeSafeState = async (itemId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/item-data/change-safe-state/${itemId}`,
|
`${API_BASE}/api/changeSafeState/${itemId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
@@ -240,19 +211,16 @@ export const changeSafeState = async (itemId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAPIentry = async (apiKey: string, name: string) => {
|
export const createAPIentry = async (apiKey: string, user: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
|
||||||
`${API_BASE}/api/admin/api-data/create-api-key`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ apiKey, entryName: name }),
|
body: JSON.stringify({ apiKey, user }),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -270,7 +238,7 @@ export const createAPIentry = async (apiKey: string, name: string) => {
|
|||||||
export const deleteAPKey = async (apiKeyId: number) => {
|
export const deleteAPKey = async (apiKeyId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/admin/api-data/delete-api-key/${apiKeyId}`,
|
`${API_BASE}/api/deleteAPKey/${apiKeyId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"ignoreDeprecations": "5.0"
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"backend-info": {
|
|
||||||
"version": "v2.0 (dev)"
|
|
||||||
},
|
|
||||||
"frontend-info": {
|
|
||||||
"version": "v2.0 (dev)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
100
backend/scheme.sql
Normal file
100
backend/scheme.sql
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
-- All necessary tables for the borrowing system
|
||||||
|
|
||||||
|
-- IMPORTANT: You need mySQL version 8.0 or newer!
|
||||||
|
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`role` int DEFAULT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `admins` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`first_name` varchar(255) NOT NULL,
|
||||||
|
`last_name` varchar(255) NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `loans` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`loan_code` int NOT NULL,
|
||||||
|
`start_date` timestamp NOT NULL,
|
||||||
|
`end_date` timestamp NOT NULL,
|
||||||
|
`take_date` timestamp NULL DEFAULT NULL,
|
||||||
|
`returned_date` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
|
||||||
|
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
|
||||||
|
`deleted` bool NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `loan_code` (`loan_code`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `items` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_name` varchar(255) NOT NULL,
|
||||||
|
`can_borrow_role` INT NOT NULL,
|
||||||
|
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `item_name` (`item_name`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `lockers` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`item` varchar(255) NOT NULL,
|
||||||
|
`locker_number` int NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `item` (`item`),
|
||||||
|
UNIQUE KEY `locker_number` (`locker_number`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `apiKeys` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`apiKey` int NOT NULL UNIQUE,
|
||||||
|
`user` VARCHAR(255) NOT NULL,
|
||||||
|
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
|
||||||
|
('DJI 1er Mikro', 4, 1),
|
||||||
|
('DJI 2er Mikro 1', 4, 1),
|
||||||
|
('DJI 2er Mikro 2', 4, 1),
|
||||||
|
('Rode Richt Mikrofon', 2, 1),
|
||||||
|
('Kamera Stativ', 1, 0),
|
||||||
|
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
|
||||||
|
('MacBook inkl. Adapter', 2, 0),
|
||||||
|
('SD Karten', 3, 0),
|
||||||
|
('Kameragimbal', 1, 0),
|
||||||
|
('ATEM MINI PRO', 1, 1),
|
||||||
|
('Handygimbal', 4, 0),
|
||||||
|
('Kameralüfter', 1, 1),
|
||||||
|
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
|
||||||
|
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
|
||||||
|
|
||||||
|
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
|
||||||
|
('DJI 1er Mikro', 1),
|
||||||
|
('DJI 2er Mikro 1', 2),
|
||||||
|
('DJI 2er Mikro 2', 3),
|
||||||
|
('Rode Richt Mikrofon', 4),
|
||||||
|
('Kamera Stativ', 5),
|
||||||
|
('SONY Kamera - inkl. Akkus und Objektiv', 6),
|
||||||
|
('MacBook inkl. Adapter', 7),
|
||||||
|
('SD Karten', 8),
|
||||||
|
('Kameragimbal', 9),
|
||||||
|
('ATEM MINI PRO', 10),
|
||||||
|
('Handygimbal', 11),
|
||||||
|
('Kameralüfter', 12),
|
||||||
|
('Kleine Kamera 1 - inkl. Objektiv', 13),
|
||||||
|
('Kleine Kamera 2 - inkl. Objektiv', 14);
|
||||||
@@ -6,7 +6,6 @@ import apiRouterV2 from "./routes/apiV2.js";
|
|||||||
env.config();
|
env.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 8002;
|
const port = 8002;
|
||||||
import serverInfo from "./info.json" assert { type: "json" }
|
|
||||||
|
|
||||||
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
|
||||||
@@ -21,10 +20,6 @@ app.get("/", (req, res) => {
|
|||||||
res.render("index.ejs");
|
res.render("index.ejs");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/server-info", async (req, res) => {
|
|
||||||
res.status(200).json(serverInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running on port: ${port}`);
|
console.log(`Server is running on port: ${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /backend
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 8004
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"backend-info": {
|
|
||||||
"version": "v2.0 (dev)"
|
|
||||||
},
|
|
||||||
"frontend-info": {
|
|
||||||
"version": "v2.0 (dev)"
|
|
||||||
},
|
|
||||||
"admin-panel-info": {
|
|
||||||
"version": "v1.2 (dev)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1105
backendV2/package-lock.json
generated
1105
backendV2/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "backendv2",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^17.2.1",
|
|
||||||
"ejs": "^3.1.10",
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"jose": "^6.0.12",
|
|
||||||
"mysql2": "^3.14.3",
|
|
||||||
"nodemailer": "^7.0.6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
getAllApiKeys,
|
|
||||||
createAPIentry,
|
|
||||||
deleteAPKey,
|
|
||||||
} from "./database/apiDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/get-api-keys", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllApiKeys();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve API keys" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/create-api-key", authenticateAdmin, async (req, res) => {
|
|
||||||
const apiKey = req.body.apiKey;
|
|
||||||
const entryName = req.body.entryName;
|
|
||||||
const result = await createAPIentry(apiKey, entryName);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json({ message: "API key created successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to create API key" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-api-key/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const apiKeyId = req.params.id;
|
|
||||||
const result = await deleteAPKey(apiKeyId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "API key deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete API key" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllApiKeys = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM apiKeys");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createAPIentry = async (apiKey, entryName) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO apiKeys (api_key, entry_name) VALUES (?, ?)",
|
|
||||||
[apiKey, entryName]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAPKey = async (apiKeyId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
|
|
||||||
apiKeyId,
|
|
||||||
]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllItems = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM items");
|
|
||||||
if (result.length > 0) return { success: true, data: result };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteItemById = async (itemId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createItem = async (item_name, can_borrow_role, in_safe) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO items (item_name, can_borrow_role, in_safe) VALUES (?, ?, ?)",
|
|
||||||
[item_name, can_borrow_role, true]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editItemById = async (itemId, item_name, can_borrow_role) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET item_name = ?, can_borrow_role = ?, entry_updated_at = NOW() WHERE id = ?",
|
|
||||||
[item_name, can_borrow_role, itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changeSafeState = async (itemId) => {
|
|
||||||
const currentState = await pool.query(
|
|
||||||
"SELECT in_safe FROM items WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (currentState[0].length === 0) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState[0][0].in_safe) {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = false WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentState[0][0].in_safe) {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET in_safe = true WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getAllLoans = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans");
|
|
||||||
return { success: true, data: rows };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteLoanById = async (loanId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const createUser = async (
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name
|
|
||||||
) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[username, role, password, isAdmin, email, first_name, last_name]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteUserById = async (userId) => {
|
|
||||||
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (userId, newPassword) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET password = ? WHERE id = ?",
|
|
||||||
[newPassword, userId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const editUserById = async (
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ? WHERE id = ?",
|
|
||||||
[first_name, last_name, role, email, is_admin, userId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllUsers = async () => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users"
|
|
||||||
);
|
|
||||||
if (result.length > 0) return { success: true, data: result };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUserById = async (userId) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
return { success: true, data: rows[0] };
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const loginAdmin = async (username, password) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
"SELECT id, username, first_name, last_name, role, is_admin FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { success: false, reason: "invalid_credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = rows[0];
|
|
||||||
if (!user.is_admin) {
|
|
||||||
return { success: false, reason: "not_admin" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: user };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const executeQuery = async (query, password, username) => {
|
|
||||||
let verified = false;
|
|
||||||
const [user] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
if (user.length > 0 && user[0].is_admin) {
|
|
||||||
verified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
const [result] = await pool.query(`${query}`);
|
|
||||||
return { success: true, data: result };
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
editItemById,
|
|
||||||
getAllItems,
|
|
||||||
deleteItemById,
|
|
||||||
createItem,
|
|
||||||
changeSafeState,
|
|
||||||
} from "./database/itemDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/all-items", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllItems();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve items" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-item/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const result = await deleteItemById(itemId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Item deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete item" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/create-item", authenticateAdmin, async (req, res) => {
|
|
||||||
const { item_name, can_borrow_role } = 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("/edit-item/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const { item_name, can_borrow_role } = req.body;
|
|
||||||
const result = await editItemById(
|
|
||||||
itemId,
|
|
||||||
item_name,
|
|
||||||
can_borrow_role
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Item edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit item" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-safe-state/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const itemId = req.params.id;
|
|
||||||
const result = await changeSafeState(itemId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Safe state changed successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to change safe state" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
deleteLoanById,
|
|
||||||
getAllLoans,
|
|
||||||
} from "./database/loanDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.get("/all-loans", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllLoans();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve loans" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-loan/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const loanId = req.params.id;
|
|
||||||
const result = await deleteLoanById(loanId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Loan deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete loan" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticateAdmin } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
deleteUserById,
|
|
||||||
editUserById,
|
|
||||||
changePassword,
|
|
||||||
getAllUsers,
|
|
||||||
getUserById,
|
|
||||||
} from "./database/userDataMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/create-user", authenticateAdmin, async (req, res) => {
|
|
||||||
const username = req.body.username;
|
|
||||||
const role = req.body.role;
|
|
||||||
const password = req.body.password;
|
|
||||||
const isAdmin = req.body.isAdmin;
|
|
||||||
const email = req.body.email;
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const result = await createUser(
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
email,
|
|
||||||
first_name,
|
|
||||||
last_name
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(201).json({ message: "User created successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to create user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const userId = req.params.id;
|
|
||||||
const result = await deleteUserById(userId);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User deleted successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to delete user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const role = req.body.role;
|
|
||||||
const email = req.body.email;
|
|
||||||
const userId = req.params.id;
|
|
||||||
const is_admin = req.body.is_admin;
|
|
||||||
|
|
||||||
const result = await editUserById(
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-password", authenticateAdmin, async (req, res) => {
|
|
||||||
const username = req.body.username;
|
|
||||||
const password = req.body.password;
|
|
||||||
|
|
||||||
const result = await changePassword(username, password);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "Password reset successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to reset password" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const userId = req.params.id;
|
|
||||||
const first_name = req.body.first_name;
|
|
||||||
const last_name = req.body.last_name;
|
|
||||||
const role = req.body.role;
|
|
||||||
const email = req.body.email;
|
|
||||||
const is_admin = req.body.is_admin;
|
|
||||||
|
|
||||||
const result = await editUserById(
|
|
||||||
userId,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
role,
|
|
||||||
email,
|
|
||||||
is_admin
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ message: "User edited successfully" });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to edit user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/users", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getAllUsers();
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve users" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/user/:id", authenticateAdmin, async (req, res) => {
|
|
||||||
const result = await getUserById(req.params.id);
|
|
||||||
if (result.success) {
|
|
||||||
return res.status(200).json({ user: result.data });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ message: "Failed to retrieve user" });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
generateToken,
|
|
||||||
authenticateAdmin,
|
|
||||||
} from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import { loginAdmin, executeQuery } from "./database/userMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/login", async (req, res) => {
|
|
||||||
const { username, password } = req.body || {};
|
|
||||||
if (!username || !password) {
|
|
||||||
return res.status(400).json({ message: "Missing username or password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await loginAdmin(username, password);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const token = await generateToken({
|
|
||||||
username: result.data.username,
|
|
||||||
first_name: result.data.first_name,
|
|
||||||
last_name: result.data.last_name,
|
|
||||||
admin: result.data.is_admin,
|
|
||||||
});
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Login erfolgreich",
|
|
||||||
token,
|
|
||||||
first_name: result.data.first_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.reason === "not_admin") {
|
|
||||||
return res.status(403).json({ message: "Du bist kein Admin" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(401).json({ message: "Ungültige Anmeldedaten" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/verify-token", authenticateAdmin, async (req, res) => {
|
|
||||||
return res.status(200).json({ message: "Token is valid" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/database-query", authenticateAdmin, async (req, res) => {
|
|
||||||
const query = req.body.query;
|
|
||||||
const password = req.body.password;
|
|
||||||
const username = req.body.username;
|
|
||||||
|
|
||||||
const result = await executeQuery(query, password, username);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const getItemsFromDatabaseV2 = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoanByCodeV2 = async (loan_code) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT first_name, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
|
|
||||||
[loan_code]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result[0] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changeInSafeStateV2 = async (itemId) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setReturnDateV2 = async (loanCode) => {
|
|
||||||
const [items] = await pool.query(
|
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) return { success: false };
|
|
||||||
|
|
||||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
|
||||||
? items[0].loaned_items_id
|
|
||||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
|
||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
|
||||||
"UPDATE items SET inSafe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
|
|
||||||
[owner[0].username, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setTakeDateV2 = async (loanCode) => {
|
|
||||||
const [items] = await pool.query(
|
|
||||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [owner] = await pool.query(
|
|
||||||
"SELECT username FROM loans WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) return { success: false };
|
|
||||||
|
|
||||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
|
||||||
? items[0].loaned_items_id
|
|
||||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
|
||||||
|
|
||||||
const [setItemStates] = await pool.query(
|
|
||||||
"UPDATE items SET inSafe = 0, currently_borrowing = (?) WHERE id IN (?)",
|
|
||||||
[owner[0].username, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
|
||||||
[loanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllLoansV2 = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM loans;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
import {
|
|
||||||
getItemsFromDatabaseV2,
|
|
||||||
changeInSafeStateV2,
|
|
||||||
setTakeDateV2,
|
|
||||||
setReturnDateV2,
|
|
||||||
getLoanByCodeV2,
|
|
||||||
} from "./api.database.js";
|
|
||||||
|
|
||||||
// Route for API to get all items from the database
|
|
||||||
router.get("/items/:key", authenticate, async (req, res) => {
|
|
||||||
const result = await getItemsFromDatabaseV2();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route for API to control the safe state of an item
|
|
||||||
router.post(
|
|
||||||
"/change-state/:key/:itemId/:state",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const itemId = req.params.itemId;
|
|
||||||
const state = req.params.state;
|
|
||||||
|
|
||||||
if (state === "1" || state === "0") {
|
|
||||||
const result = await changeInSafeStateV2(itemId, state);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to update item state" });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res.status(400).json({ message: "Invalid state value" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to get a loan by its code
|
|
||||||
router.get(
|
|
||||||
"/get-loan-by-code/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loan_code = req.params.loan_code;
|
|
||||||
const result = await getLoanByCodeV2(loan_code);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ message: "Loan not found" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to set the return date by the loan code
|
|
||||||
router.post(
|
|
||||||
"/set-return-date/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loanCode = req.params.loan_code;
|
|
||||||
const result = await setReturnDateV2(loanCode);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to set return date" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Route for API to set the take away date by the loan code
|
|
||||||
router.post(
|
|
||||||
"/set-take-date/:key/:loan_code",
|
|
||||||
authenticate,
|
|
||||||
async (req, res) => {
|
|
||||||
const loanCode = req.params.loan_code;
|
|
||||||
const result = await setTakeDateV2(loanCode);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ data: result.data });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to set take date" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const createLoanInDatabase = async (
|
|
||||||
username,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
note,
|
|
||||||
itemIds
|
|
||||||
) => {
|
|
||||||
if (!username)
|
|
||||||
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
|
|
||||||
if (!Array.isArray(itemIds) || itemIds.length === 0)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "No items provided",
|
|
||||||
};
|
|
||||||
if (!startDate || !endDate)
|
|
||||||
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
|
|
||||||
|
|
||||||
const start = new Date(startDate);
|
|
||||||
const end = new Date(endDate);
|
|
||||||
if (
|
|
||||||
!(start instanceof Date) ||
|
|
||||||
isNaN(start.getTime()) ||
|
|
||||||
!(end instanceof Date) ||
|
|
||||||
isNaN(end.getTime()) ||
|
|
||||||
start >= end
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Invalid date range",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
// Ensure all items exist and collect names + lockers
|
|
||||||
const [itemsRows] = await conn.query(
|
|
||||||
"SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
|
|
||||||
[itemIds]
|
|
||||||
);
|
|
||||||
if (!itemsRows || itemsRows.length !== itemIds.length) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "One or more items not found",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemNames = itemIds
|
|
||||||
.map(
|
|
||||||
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
|
|
||||||
)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
// Build lockers array (unique, only 2-digit strings)
|
|
||||||
const lockers = [
|
|
||||||
...new Set(
|
|
||||||
itemsRows
|
|
||||||
.map((r) => r.safe_nr)
|
|
||||||
.filter((sn) => typeof sn === "string" && /^\d{2}$/.test(sn))
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check availability (no overlap with existing loans)
|
|
||||||
const [confRows] = await conn.query(
|
|
||||||
`
|
|
||||||
SELECT COUNT(*) AS conflicts
|
|
||||||
FROM loans l
|
|
||||||
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
|
||||||
ON TRUE
|
|
||||||
WHERE jt.item_id IN (?)
|
|
||||||
AND l.deleted = 0
|
|
||||||
AND l.start_date < ?
|
|
||||||
AND COALESCE(l.returned_date, l.end_date) > ?
|
|
||||||
`,
|
|
||||||
[itemIds, end, start]
|
|
||||||
);
|
|
||||||
if (confRows?.[0]?.conflicts > 0) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "CONFLICT",
|
|
||||||
message: "One or more items are not available in the selected period",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique loan_code (retry a few times)
|
|
||||||
let loanCode = null;
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
|
|
||||||
const [exists] = await conn.query(
|
|
||||||
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
|
||||||
[candidate]
|
|
||||||
);
|
|
||||||
if (exists.length === 0) {
|
|
||||||
loanCode = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!loanCode) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
message: "Failed to generate unique loan code",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert loan (now includes lockers)
|
|
||||||
const [insertRes] = await conn.query(
|
|
||||||
`
|
|
||||||
INSERT INTO loans (username, loan_code, start_date, end_date, lockers, loaned_items_id, loaned_items_name, note)
|
|
||||||
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), CAST(? AS JSON), ?)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
username,
|
|
||||||
loanCode,
|
|
||||||
new Date(start).toISOString().slice(0, 19).replace("T", " "),
|
|
||||||
new Date(end).toISOString().slice(0, 19).replace("T", " "),
|
|
||||||
JSON.stringify(lockers),
|
|
||||||
JSON.stringify(itemIds.map((n) => Number(n))),
|
|
||||||
JSON.stringify(itemNames),
|
|
||||||
note,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
await conn.commit();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: insertRes.insertId,
|
|
||||||
loan_code: loanCode,
|
|
||||||
username,
|
|
||||||
start_date: start,
|
|
||||||
end_date: end,
|
|
||||||
items: itemIds,
|
|
||||||
item_names: itemNames,
|
|
||||||
lockers,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
console.error("createLoanInDatabase error:", err);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
message: "Failed to create loan",
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
conn.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoanInfoWithID = async (loanId) => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
|
|
||||||
loanId,
|
|
||||||
]);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows[0] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoansFromDatabase = async (username) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
|
|
||||||
[username]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, status: true, data: result };
|
|
||||||
} else if (result.length === 0) {
|
|
||||||
return { success: true, status: true, data: [] };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBorrowableItemsFromDatabase = async (
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
role = 0
|
|
||||||
) => {
|
|
||||||
// Overlap if: loan.start < end AND effective_end > start
|
|
||||||
// effective_end is returned_date if set, otherwise end_date
|
|
||||||
const hasRoleFilter = Number(role) > 0;
|
|
||||||
|
|
||||||
const sql = `
|
|
||||||
SELECT i.*
|
|
||||||
FROM items i
|
|
||||||
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM loans l
|
|
||||||
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
|
|
||||||
WHERE jt.item_id = i.id
|
|
||||||
AND l.deleted = 0
|
|
||||||
AND l.start_date < ?
|
|
||||||
AND COALESCE(l.returned_date, l.end_date) > ?
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params = hasRoleFilter
|
|
||||||
? [role, endDate, startDate]
|
|
||||||
: [endDate, startDate];
|
|
||||||
|
|
||||||
const [rows] = await pool.query(sql, params);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SETdeleteLoanFromDatabase = async (loanId) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE loans SET deleted = 1 WHERE id = ?;",
|
|
||||||
[loanId]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) {
|
|
||||||
return { success: true };
|
|
||||||
} else {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getALLLoans = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM loans WHERE deleted = 0;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getItems = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const loginFunc = async (username, password) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, password]
|
|
||||||
);
|
|
||||||
if (result.length > 0) return { success: true, data: result[0] };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getItems = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM items;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getALLLoans = async () => {
|
|
||||||
const [rows] = await pool.query("SELECT * FROM loans;");
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return { success: true, data: rows };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (username, oldPassword, newPassword) => {
|
|
||||||
// get user current password
|
|
||||||
const [user] = await pool.query(
|
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
|
||||||
[username, oldPassword]
|
|
||||||
);
|
|
||||||
if (user.length === 0) return { success: false };
|
|
||||||
|
|
||||||
// update password
|
|
||||||
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"UPDATE users SET password = ? WHERE username = ?",
|
|
||||||
[newPassword, username]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) return { success: true };
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import {
|
|
||||||
createLoanInDatabase,
|
|
||||||
getLoanInfoWithID,
|
|
||||||
getLoansFromDatabase,
|
|
||||||
getBorrowableItemsFromDatabase,
|
|
||||||
getALLLoans,
|
|
||||||
getItems,
|
|
||||||
SETdeleteLoanFromDatabase,
|
|
||||||
} from "./database/loansMgmt.database.js";
|
|
||||||
import { sendMailLoan } from "./services/mailer.js";
|
|
||||||
|
|
||||||
router.post("/createLoan", authenticate, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { items, startDate, endDate, note } = req.body || {};
|
|
||||||
|
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
|
||||||
return res.status(400).json({ message: "Items array is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dates are not provided, default to now .. +7 days
|
|
||||||
const start =
|
|
||||||
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
||||||
const end =
|
|
||||||
endDate ??
|
|
||||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 19)
|
|
||||||
.replace("T", " ");
|
|
||||||
|
|
||||||
// Coerce item IDs to numbers and filter invalids
|
|
||||||
const itemIds = items
|
|
||||||
.map((v) => Number(v))
|
|
||||||
.filter((n) => Number.isFinite(n));
|
|
||||||
|
|
||||||
if (itemIds.length === 0) {
|
|
||||||
return res.status(400).json({ message: "No valid item IDs provided" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createLoanInDatabase(
|
|
||||||
req.user.username,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
note,
|
|
||||||
itemIds
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
|
||||||
console.log(mailInfo);
|
|
||||||
sendMailLoan(
|
|
||||||
mailInfo.data.username,
|
|
||||||
mailInfo.data.loaned_items_name,
|
|
||||||
mailInfo.data.start_date,
|
|
||||||
mailInfo.data.end_date,
|
|
||||||
mailInfo.data.created_at
|
|
||||||
);
|
|
||||||
return res.status(201).json({
|
|
||||||
message: "Loan created successfully",
|
|
||||||
loanId: result.data.id,
|
|
||||||
loanCode: result.data.loan_code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.code === "CONFLICT") {
|
|
||||||
return res
|
|
||||||
.status(409)
|
|
||||||
.json({ message: "Items not available in the selected period" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.code === "BAD_REQUEST") {
|
|
||||||
return res.status(400).json({ message: result.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({ message: "Failed to create loan" });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("createLoan error:", err);
|
|
||||||
return res.status(500).json({ message: "Failed to create loan" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/loans", authenticate, async (req, res) => {
|
|
||||||
const result = await getLoansFromDatabase(req.user.username);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else if (result.status) {
|
|
||||||
res.status(200).json([]);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch loans" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/all-items", authenticate, async (req, res) => {
|
|
||||||
const result = await getItems();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
|
|
||||||
const loanId = req.params.id;
|
|
||||||
const result = await SETdeleteLoanFromDatabase(loanId);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ message: "Loan deleted successfully" });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to delete loan" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/all-loans", authenticate, async (req, res) => {
|
|
||||||
const result = await getALLLoans();
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to fetch loans" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/borrowable-items", authenticate, async (req, res) => {
|
|
||||||
const { startDate, endDate } = req.body || {};
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ message: "startDate and endDate are required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getBorrowableItemsFromDatabase(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
req.user.role
|
|
||||||
);
|
|
||||||
if (result.success) {
|
|
||||||
// return the array directly for consistency with /items
|
|
||||||
return res.status(200).json(result.data);
|
|
||||||
} else {
|
|
||||||
return res
|
|
||||||
.status(500)
|
|
||||||
.json({ message: "Failed to fetch borrowable items" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import nodemailer from "nodemailer";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
|
||||||
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
|
||||||
const itemsList =
|
|
||||||
Array.isArray(items) && items.length
|
|
||||||
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
|
|
||||||
.map(
|
|
||||||
(i) =>
|
|
||||||
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
|
|
||||||
)
|
|
||||||
.join("")}</ul>`
|
|
||||||
: "<span style='color:#111827;'>N/A</span>";
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="color-scheme" content="light">
|
|
||||||
<meta name="supported-color-schemes" content="light">
|
|
||||||
<meta name="x-apple-disable-message-reformatting">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; supported-color-schemes: light; }
|
|
||||||
body { margin:0; padding:0; }
|
|
||||||
/* Mobile stacking */
|
|
||||||
@media (max-width:480px) {
|
|
||||||
.outer { width:100% !important; }
|
|
||||||
.pad-sm { padding:16px !important; }
|
|
||||||
.w-label { width:120px !important; }
|
|
||||||
}
|
|
||||||
/* Dark-mode override safety */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
|
|
||||||
.brand-header { background:${brand} !important; color:#ffffff !important; }
|
|
||||||
a { color:${brand} !important; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
|
|
||||||
<!-- Preheader (hidden) -->
|
|
||||||
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
|
|
||||||
Neue Ausleihe erstellt – Übersicht der Buchung.
|
|
||||||
</div>
|
|
||||||
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
|
|
||||||
<tr>
|
|
||||||
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
|
|
||||||
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
|
|
||||||
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
|
|
||||||
user || "N/A"
|
|
||||||
}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
|
||||||
startDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
|
||||||
endDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
|
|
||||||
createdDate
|
|
||||||
)}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p style="margin:22px 0 0 0; font-size:14px;">
|
|
||||||
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
|
|
||||||
Übersicht öffnen
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
|
|
||||||
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
|
||||||
const itemsText =
|
|
||||||
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
|
||||||
return [
|
|
||||||
"Neue Ausleihe erstellt",
|
|
||||||
"",
|
|
||||||
`Benutzer: ${user || "N/A"}`,
|
|
||||||
`Gegenstände: ${itemsText}`,
|
|
||||||
`Start: ${formatDateTime(startDate)}`,
|
|
||||||
`Ende: ${formatDateTime(endDate)}`,
|
|
||||||
`Erstellt am: ${formatDateTime(createdDate)}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.MAIL_HOST,
|
|
||||||
port: process.env.MAIL_PORT,
|
|
||||||
secure: true,
|
|
||||||
auth: {
|
|
||||||
user: process.env.MAIL_USER,
|
|
||||||
pass: process.env.MAIL_PASSWORD,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
|
||||||
to: process.env.MAIL_SENDEES,
|
|
||||||
subject: "Eine neue Ausleihe wurde erstellt!",
|
|
||||||
text: buildLoanEmailText({
|
|
||||||
user,
|
|
||||||
items,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
createdDate,
|
|
||||||
}),
|
|
||||||
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Message sent:", info.messageId);
|
|
||||||
})();
|
|
||||||
console.log("sendMailLoan called");
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
|
||||||
const router = express.Router();
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// database funcs import
|
|
||||||
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
|
|
||||||
|
|
||||||
router.post("/login", async (req, res) => {
|
|
||||||
const result = await loginFunc(req.body.username, req.body.password);
|
|
||||||
if (result.success) {
|
|
||||||
const token = await generateToken({
|
|
||||||
username: result.data.username,
|
|
||||||
role: result.data.role,
|
|
||||||
});
|
|
||||||
res.status(200).json({ message: "Login successful", token });
|
|
||||||
} else {
|
|
||||||
res.status(401).json({ message: "Invalid credentials" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/change-password", authenticate, async (req, res) => {
|
|
||||||
const oldPassword = req.body.oldPassword;
|
|
||||||
const newPassword = req.body.newPassword;
|
|
||||||
const username = req.user.username;
|
|
||||||
const result = await changePassword(username, oldPassword, newPassword);
|
|
||||||
if (result.success) {
|
|
||||||
res.status(200).json({ message: "Password changed successfully" });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ message: "Failed to change password" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
Binary file not shown.
@@ -1,120 +0,0 @@
|
|||||||
USE borrow_system_new;
|
|
||||||
|
|
||||||
-- Reset tables (no FKs defined, so order is safe)
|
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
|
||||||
TRUNCATE TABLE loans;
|
|
||||||
TRUNCATE TABLE apiKeys;
|
|
||||||
TRUNCATE TABLE items;
|
|
||||||
TRUNCATE TABLE users;
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
|
||||||
|
|
||||||
-- Users (roles 1–6, plain-text passwords)
|
|
||||||
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin) VALUES
|
|
||||||
('admin', 'adminpass', 'admin@example.com', 'System', 'Admin', 6, true),
|
|
||||||
('alice', 'alice123', 'alice@example.com', 'Alice', 'Andersen',1, false),
|
|
||||||
('bob', 'bob12345', 'bob@example.com', 'Bob', 'Berg', 2, false),
|
|
||||||
('carol', 'carol123', 'carol@example.com', 'Carol', 'Christensen', 3, false),
|
|
||||||
('dave', 'dave123', 'dave@example.com', 'Dave', 'Dahl', 4, false),
|
|
||||||
('erin', 'erin123', 'erin@example.com', 'Erin', 'Enevoldsen', 5, false),
|
|
||||||
('frank', 'frank123', 'frank@example.com', 'Frank', 'Fisher', 2, false),
|
|
||||||
('grace', 'grace123', 'grace@example.com', 'Grace', 'Gundersen',1, false),
|
|
||||||
('heidi', 'heidi123', 'heidi@example.com', 'Heidi', 'Hansen', 4, false),
|
|
||||||
('tech', 'techpass', 'tech@example.com', 'Tech', 'User', 5, true);
|
|
||||||
|
|
||||||
-- Items (safe_nr is two digits or NULL; currently_borrowing aligns with active loans)
|
|
||||||
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, last_borrowed_person, currently_borrowing) VALUES
|
|
||||||
('Laptop A', 2, false, NULL, 'grace', 'bob'),
|
|
||||||
('Laptop B', 2, true, '01', NULL, NULL),
|
|
||||||
('Camera Canon', 3, true, '02', 'erin', NULL),
|
|
||||||
('Microphone Rode', 1, true, '03', 'grace', NULL),
|
|
||||||
('Tripod Manfrotto', 1, true, '04', 'frank', NULL),
|
|
||||||
('Oscilloscope Tek', 4, true, '05', NULL, NULL),
|
|
||||||
('VR Headset', 3, false, NULL, 'heidi', 'carol'),
|
|
||||||
('Keycard Programmer', 6, true, '06', 'admin', NULL);
|
|
||||||
|
|
||||||
-- Loans (JSON arrays, 6-digit numeric loan_code)
|
|
||||||
-- Assumes the items above have ids 1..8 in insert order
|
|
||||||
INSERT INTO loans (
|
|
||||||
username,
|
|
||||||
lockers,
|
|
||||||
loan_code,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
take_date,
|
|
||||||
returned_date,
|
|
||||||
loaned_items_id,
|
|
||||||
loaned_items_name,
|
|
||||||
deleted,
|
|
||||||
note
|
|
||||||
) VALUES
|
|
||||||
-- Active loan: bob has Laptop A
|
|
||||||
('bob',
|
|
||||||
'["01"]',
|
|
||||||
'123456',
|
|
||||||
'2025-11-15 09:00:00',
|
|
||||||
'2025-11-22 17:00:00',
|
|
||||||
'2025-11-15 09:15:00',
|
|
||||||
NULL,
|
|
||||||
'[1]',
|
|
||||||
'["Laptop A"]',
|
|
||||||
false,
|
|
||||||
'Active loan - Laptop A'
|
|
||||||
),
|
|
||||||
-- Returned loan: frank had Tripod Manfrotto
|
|
||||||
('frank',
|
|
||||||
'["04"]',
|
|
||||||
'234567',
|
|
||||||
'2025-10-01 10:00:00',
|
|
||||||
'2025-10-07 16:00:00',
|
|
||||||
'2025-10-01 10:05:00',
|
|
||||||
'2025-10-05 15:30:00',
|
|
||||||
'[5]',
|
|
||||||
'["Tripod Manfrotto"]',
|
|
||||||
false,
|
|
||||||
'Completed loan'
|
|
||||||
),
|
|
||||||
-- Future reservation: dave will take Oscilloscope Tek
|
|
||||||
('dave',
|
|
||||||
'["05"]',
|
|
||||||
'345678',
|
|
||||||
'2025-12-10 09:00:00',
|
|
||||||
'2025-12-12 17:00:00',
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
'[6]',
|
|
||||||
'["Oscilloscope Tek"]',
|
|
||||||
false,
|
|
||||||
'Reserved'
|
|
||||||
),
|
|
||||||
-- Active loan: carol has VR Headset
|
|
||||||
('carol',
|
|
||||||
'["02"]',
|
|
||||||
'456789',
|
|
||||||
'2025-11-10 13:00:00',
|
|
||||||
'2025-11-20 12:00:00',
|
|
||||||
'2025-11-10 13:10:00',
|
|
||||||
NULL,
|
|
||||||
'[7]',
|
|
||||||
'["VR Headset"]',
|
|
||||||
false,
|
|
||||||
'Active loan - VR Headset'
|
|
||||||
),
|
|
||||||
-- Soft-deleted historic loan: grace had Microphone + Tripod
|
|
||||||
('grace',
|
|
||||||
'["03","04"]',
|
|
||||||
'567890',
|
|
||||||
'2025-09-01 09:00:00',
|
|
||||||
'2025-09-03 17:00:00',
|
|
||||||
'2025-09-01 09:10:00',
|
|
||||||
'2025-09-03 16:45:00',
|
|
||||||
'[4,5]',
|
|
||||||
'["Microphone Rode","Tripod Manfrotto"]',
|
|
||||||
true,
|
|
||||||
'Canceled/soft-deleted record'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- API keys (8-digit numeric keys)
|
|
||||||
INSERT INTO apiKeys (api_key, entry_name, last_used_at) VALUES
|
|
||||||
('12345678', 'CI token', '2025-11-15 08:00:00'),
|
|
||||||
('87654321', 'Local dev', NULL),
|
|
||||||
('00000001', 'Monitoring', '2025-11-10 12:30:00');
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
use borrow_system_new;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
username varchar(100) NOT NULL UNIQUE,
|
|
||||||
password varchar(255) NOT NULL,
|
|
||||||
email varchar(255) NOT NULL,
|
|
||||||
first_name varchar(255) NOT NULL,
|
|
||||||
last_name varchar(255) NOT NULL,
|
|
||||||
role int NOT NULL,
|
|
||||||
is_admin bool NOT NULL DEFAULT false,
|
|
||||||
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE loans (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
username varchar(100) NOT NULL,
|
|
||||||
lockers json NOT NULL DEFAULT ('[]'),
|
|
||||||
loan_code Char(6) NOT NULL UNIQUE,
|
|
||||||
start_date timestamp NOT NULL,
|
|
||||||
end_date timestamp NOT NULL,
|
|
||||||
take_date timestamp NULL DEFAULT NULL,
|
|
||||||
returned_date timestamp NULL DEFAULT NULL,
|
|
||||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
loaned_items_id json NOT NULL DEFAULT ('[]'),
|
|
||||||
loaned_items_name json NOT NULL DEFAULT ('[]'),
|
|
||||||
deleted bool NOT NULL DEFAULT false,
|
|
||||||
note varchar(500) DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CHECK (loan_code REGEXP '^[0-9]{6}$')
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE items (
|
|
||||||
id int NOT NULL AUTO_INCREMENT,
|
|
||||||
item_name varchar(255) NOT NULL UNIQUE,
|
|
||||||
can_borrow_role INT NOT NULL,
|
|
||||||
in_safe bool NOT NULL DEFAULT true,
|
|
||||||
safe_nr CHAR(2) DEFAULT NULL,
|
|
||||||
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
last_borrowed_person varchar(255) DEFAULT NULL,
|
|
||||||
currently_borrowing varchar(255) DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CHECK (safe_nr REGEXP '^[0-9]{2}$' OR safe_nr IS NULL)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE apiKeys (
|
|
||||||
id INT NOT NULL AUTO_INCREMENT,
|
|
||||||
api_key CHAR(8) NOT NULL UNIQUE,
|
|
||||||
entry_name VARCHAR(100) NOT NULL,
|
|
||||||
last_used_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CHECK (api_key REGEXP '^[0-9]{8}$')
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
import env from "dotenv";
|
|
||||||
import info from "./info.json" assert { type: "json" };
|
|
||||||
import { authenticate } from "./services/authentication.js";
|
|
||||||
|
|
||||||
// frontend routes
|
|
||||||
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
|
|
||||||
import userMgmtRouterAPP from "./routes/app/userMgmt.route.js";
|
|
||||||
|
|
||||||
// admin routes
|
|
||||||
import userDataMgmtRouter from "./routes/admin/userDataMgmt.route.js";
|
|
||||||
import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
|
|
||||||
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
|
|
||||||
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
|
|
||||||
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
import apiRouter from "./routes/api/api.route.js";
|
|
||||||
|
|
||||||
env.config();
|
|
||||||
const app = express();
|
|
||||||
const port = 8004;
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
// Body-Parser VOR den Routen registrieren
|
|
||||||
app.use(express.json({ limit: "10mb" }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
|
||||||
|
|
||||||
// frontend routes
|
|
||||||
app.use("/api/loans", loansMgmtRouter);
|
|
||||||
app.use("/api/users", userMgmtRouterAPP);
|
|
||||||
|
|
||||||
// admin routes
|
|
||||||
app.use("/api/admin/loan-data", loanDataMgmtRouter);
|
|
||||||
app.use("/api/admin/user-data", userDataMgmtRouter);
|
|
||||||
app.use("/api/admin/item-data", itemDataMgmtRouter);
|
|
||||||
app.use("/api/admin/api-data", apiDataMgmtRouter);
|
|
||||||
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.use("/api", apiRouter);
|
|
||||||
|
|
||||||
app.set("view engine", "ejs");
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server is running on port: ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/verify", authenticate, async (req, res) => {
|
|
||||||
res.status(200).json({ message: "Token is valid", user: req.user });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
res.send(info);
|
|
||||||
});
|
|
||||||
|
|
||||||
// error handling code
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error(err.stack);
|
|
||||||
res.status(500).send("Something broke!");
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
|
||||||
import env from "dotenv";
|
|
||||||
import { verifyAPIKeyDB } from "./database.js";
|
|
||||||
env.config();
|
|
||||||
|
|
||||||
const secretKey = process.env.SECRET_KEY;
|
|
||||||
if (!secretKey) {
|
|
||||||
throw new Error("Missing SECRET_KEY environment variable");
|
|
||||||
}
|
|
||||||
const secret = new TextEncoder().encode(secretKey);
|
|
||||||
|
|
||||||
export async function generateToken(payload) {
|
|
||||||
return await new SignJWT(payload)
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime("2h")
|
|
||||||
.sign(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authenticateAdmin(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
if (!authHeader) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [scheme, token] = authHeader.split(" ");
|
|
||||||
if (!/^Bearer$/i.test(scheme) || !token) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
if (!payload?.admin) {
|
|
||||||
return res.status(403).json({ message: "Forbidden: admin only" });
|
|
||||||
}
|
|
||||||
req.user = payload;
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "Forbidden 403" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authenticate(req, res, next) {
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
const apiKey = req.params.key;
|
|
||||||
|
|
||||||
if (authHeader) {
|
|
||||||
const parts = authHeader.split(" ");
|
|
||||||
const scheme = parts[0];
|
|
||||||
const token = parts[1];
|
|
||||||
|
|
||||||
if (!/^Bearer$/i.test(scheme) || !token) {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
req.user = payload;
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
|
|
||||||
}
|
|
||||||
} else if (apiKey) {
|
|
||||||
try {
|
|
||||||
await verifyAPIKey(apiKey);
|
|
||||||
return next();
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ message: "API Key invalid" }); // fix: don't chain after sendStatus
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return res.status(401).json({ message: "Unauthorized" }); // no credentials
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyAPIKey(apiKey) {
|
|
||||||
const result = await verifyAPIKeyDB(apiKey);
|
|
||||||
|
|
||||||
if (result.valid) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid API Key");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyToken(token) {
|
|
||||||
const { payload } = await jwtVerify(token, secret, {
|
|
||||||
algorithms: ["HS256"],
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import mysql from "mysql2";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const pool = mysql
|
|
||||||
.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
})
|
|
||||||
.promise();
|
|
||||||
|
|
||||||
export const verifyAPIKeyDB = async (apiKey) => {
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"SELECT * FROM apiKeys WHERE api_key = ?;",
|
|
||||||
[apiKey]
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { valid: true };
|
|
||||||
} else {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>backend</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
backend
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,51 +1,42 @@
|
|||||||
services:
|
services:
|
||||||
# usr-frontend_v2:
|
# borrow_system-frontend:
|
||||||
# container_name: borrow_system-usr-frontend
|
# container_name: borrow_system-frontend
|
||||||
# build: ./FrontendV2
|
# build: ./FrontendV2
|
||||||
# ports:
|
# ports:
|
||||||
# - "8001:80"
|
# - "8001:8001"
|
||||||
|
# environment:
|
||||||
|
# - CHOKIDAR_USEPOLLING=true
|
||||||
|
# volumes:
|
||||||
|
# - ./FrontendV2:/app
|
||||||
|
# - /app/node_modules
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
# admin-frontend:
|
# admin-frontend:
|
||||||
# container_name: borrow_system-admin-frontend
|
# container_name: admin-frontend
|
||||||
# build: ./admin
|
# build: ./admin
|
||||||
# ports:
|
# ports:
|
||||||
# - "8003:80"
|
# - "8003:8003"
|
||||||
# restart: unless-stopped
|
|
||||||
|
|
||||||
#backend:
|
|
||||||
# container_name: borrow_system-backend
|
|
||||||
# build: ./backend
|
|
||||||
# ports:
|
|
||||||
# - "8002:8002"
|
|
||||||
# environment:
|
# environment:
|
||||||
# NODE_ENV: production
|
# - CHOKIDAR_USEPOLLING=true
|
||||||
# DB_HOST: mysql
|
# volumes:
|
||||||
# DB_USER: root
|
# - ./admin:/app
|
||||||
# DB_PASSWORD: ${DB_PASSWORD}
|
# - /app/node_modules
|
||||||
# DB_NAME: borrow_system
|
|
||||||
# depends_on:
|
|
||||||
# - mysql
|
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
# healthcheck:
|
|
||||||
# test: ["CMD", "wget", "-qO-", "http://localhost:8002/server-info"]
|
|
||||||
# interval: 30s
|
|
||||||
# timeout: 5s
|
|
||||||
# retries: 3
|
|
||||||
|
|
||||||
backend_v2:
|
borrow_system-backend:
|
||||||
container_name: borrow_system-backend_v2
|
container_name: borrow_system-backend
|
||||||
build: ./backendV2
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "8004:8004"
|
- "8002:8002"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
DB_HOST: mysql
|
||||||
DB_HOST: mysql_v2
|
|
||||||
DB_USER: root
|
DB_USER: root
|
||||||
DB_PASSWORD: ${DB_PASSWORD_V2}
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_NAME: borrow_system_new
|
DB_NAME: borrow_system
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql_v2
|
- mysql
|
||||||
|
volumes:
|
||||||
|
- ./backend:/borrow_system-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
@@ -62,20 +53,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3309:3306"
|
- "3309:3306"
|
||||||
|
|
||||||
mysql_v2:
|
|
||||||
container_name: borrow_system-mysql-v2
|
|
||||||
image: mysql:8.0
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
|
|
||||||
MYSQL_DATABASE: borrow_system_new
|
|
||||||
TZ: Europe/Berlin
|
|
||||||
volumes:
|
|
||||||
- mysql-v2-data:/var/lib/mysql
|
|
||||||
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
|
||||||
ports:
|
|
||||||
- "3310:3306"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
mysql-v2-data:
|
|
||||||
|
|||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.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
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
69
frontend/README.md
Normal file
69
frontend/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
frontend/eslint.config.js
Normal file
23
frontend/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
frontend/index.html
Normal file
13
frontend/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="/shapes.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ausleihsystem</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4664
frontend/package-lock.json
generated
Normal file
4664
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tanstack/react-query": "^5.85.5",
|
||||||
|
"jotai": "^2.15.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primereact": "^10.9.6",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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
frontend/src/App.css
Normal file
1
frontend/src/App.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
54
frontend/src/App.tsx
Normal file
54
frontend/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import "./App.css";
|
||||||
|
import Layout from "./layout/Layout";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AddLoan } from "./components/AddLoan";
|
||||||
|
import LoginForm from "./components/LoginForm";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import {
|
||||||
|
fetchAllData,
|
||||||
|
ALL_ITEMS_UPDATED_EVENT,
|
||||||
|
AUTH_LOGOUT_EVENT,
|
||||||
|
} from "./utils/fetchData";
|
||||||
|
import { myToast } from "./utils/toastify";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
if (token) {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
fetchAllData(token);
|
||||||
|
}
|
||||||
|
localStorage.setItem("borrowableItems", JSON.stringify([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAuthLogout = () => {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
};
|
||||||
|
window.addEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
|
||||||
|
return () => window.removeEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Cookies.remove("token");
|
||||||
|
localStorage.removeItem("allItems");
|
||||||
|
localStorage.removeItem("allLoans");
|
||||||
|
localStorage.removeItem("userLoans");
|
||||||
|
localStorage.removeItem("borrowableItems");
|
||||||
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
|
myToast("Logged out successfully!", "success");
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return isLoggedIn ? (
|
||||||
|
<Layout onLogout={handleLogout}>
|
||||||
|
<AddLoan />
|
||||||
|
</Layout>
|
||||||
|
) : (
|
||||||
|
<LoginForm onLogin={() => setIsLoggedIn(true)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
6
frontend/src/States/Atoms.tsx
Normal file
6
frontend/src/States/Atoms.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
// Atoms to store the start and end dates for loans
|
||||||
|
export const startDate = atom<string | null>(null);
|
||||||
|
export const endDate = atom<string | null>(null);
|
||||||
|
export const getBorrowableItemsAtom = atom<string[] | boolean>(false);
|
||||||
36
frontend/src/States/README.md
Normal file
36
frontend/src/States/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# How to use Atoms
|
||||||
|
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
||||||
|
|
||||||
|
You can also name it global state.
|
||||||
|
|
||||||
|
## Creating an Atom
|
||||||
|
to create an atom you have to declare an atom like this:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
||||||
|
```
|
||||||
|
|
||||||
|
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
|
||||||
|
|
||||||
|
## Using an Atom
|
||||||
|
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Current value: {value}</p>
|
||||||
|
<button onClick={() => setValue(newValue)}>Update Value</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/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 |
69
frontend/src/components/AddLoan.tsx
Normal file
69
frontend/src/components/AddLoan.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { getBorrowableItems } from "../utils/fetchData";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { startDate, endDate } from "../States/Atoms";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
export const AddLoan = () => {
|
||||||
|
const [start, setStart] = useAtom(startDate);
|
||||||
|
const [end, setEnd] = useAtom(endDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||||
|
1. Zeitraum wählen
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget as HTMLFormElement;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const start = (fd.get("startDate") as string) || "";
|
||||||
|
const end = (fd.get("endDate") as string) || "";
|
||||||
|
setStart(start);
|
||||||
|
setEnd(end);
|
||||||
|
Cookies.set("startDate", start);
|
||||||
|
Cookies.set("endDate", end);
|
||||||
|
getBorrowableItems();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="startDate"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="startDate"
|
||||||
|
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>
|
||||||
|
<label
|
||||||
|
htmlFor="endDate"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Ende
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="endDate"
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
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;
|
||||||
65
frontend/src/components/Form1.tsx
Normal file
65
frontend/src/components/Form1.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { getBorrowableItems } from "../utils/fetchData";
|
||||||
|
|
||||||
|
const Form1: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||||
|
1. Zeitraum wählen
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget as HTMLFormElement;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const start = (fd.get("startDate") as string) || "";
|
||||||
|
const end = (fd.get("endDate") as string) || "";
|
||||||
|
Cookies.set("startDate", start);
|
||||||
|
Cookies.set("endDate", end);
|
||||||
|
getBorrowableItems();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="startDate"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="startDate"
|
||||||
|
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>
|
||||||
|
<label
|
||||||
|
htmlFor="endDate"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Ende
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="endDate"
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Form1;
|
||||||
186
frontend/src/components/Form2.tsx
Normal file
186
frontend/src/components/Form2.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
|
||||||
|
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
||||||
|
|
||||||
|
interface BorrowItem {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
can_borrow_role: string;
|
||||||
|
inSafe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = "borrowableItems";
|
||||||
|
|
||||||
|
function normalizeBorrowable(data: any): BorrowItem[] {
|
||||||
|
const rawArr = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray(data?.items)
|
||||||
|
? data.items
|
||||||
|
: Array.isArray(data?.data)
|
||||||
|
? data.data
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return rawArr
|
||||||
|
.map((raw: any) => {
|
||||||
|
const idRaw =
|
||||||
|
raw.id ?? raw.item_id ?? raw.itemId ?? raw.itemID ?? raw.itemIdPk;
|
||||||
|
const id = Number(idRaw);
|
||||||
|
const item_name = String(raw.item_name ?? raw.name ?? raw.title ?? "");
|
||||||
|
const can_borrow_role = String(
|
||||||
|
raw.can_borrow_role ?? raw.role ?? raw.requiredRole ?? ""
|
||||||
|
);
|
||||||
|
const inSafeRaw =
|
||||||
|
raw.inSafe ?? raw.in_safe ?? raw.inLocker ?? raw.isInSafe ?? raw.safe;
|
||||||
|
const inSafe =
|
||||||
|
typeof inSafeRaw === "boolean"
|
||||||
|
? Number(inSafeRaw)
|
||||||
|
: Number(isNaN(Number(inSafeRaw)) ? 0 : Number(inSafeRaw));
|
||||||
|
|
||||||
|
if (!Number.isFinite(id) || !item_name) return null;
|
||||||
|
return { id, item_name, can_borrow_role, inSafe };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as BorrowItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBorrowableItems() {
|
||||||
|
const [items, setItems] = React.useState<BorrowItem[]>([]);
|
||||||
|
|
||||||
|
const readFromStorage = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]";
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const arr = normalizeBorrowable(parsed);
|
||||||
|
setItems(arr);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
readFromStorage();
|
||||||
|
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
|
||||||
|
const onBorrowableUpdated = () => readFromStorage();
|
||||||
|
window.addEventListener(
|
||||||
|
BORROWABLE_ITEMS_UPDATED_EVENT,
|
||||||
|
onBorrowableUpdated
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(
|
||||||
|
BORROWABLE_ITEMS_UPDATED_EVENT,
|
||||||
|
onBorrowableUpdated
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [readFromStorage]);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Form2: React.FC = () => {
|
||||||
|
const items = useBorrowableItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||||
|
2. Gegenstand auswählen
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Mobile: card list */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<label
|
||||||
|
key={item.id}
|
||||||
|
htmlFor={`item-${item.id}`}
|
||||||
|
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-200 bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-900 truncate">
|
||||||
|
{item.item_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{item.inSafe ? "Verfügbar" : "Nicht im Schließfach"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`item-${item.id}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) addToRemove(item.id);
|
||||||
|
else rmFromRemove(item.id);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{items.map((item) => (
|
||||||
|
<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-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
createLoan(
|
||||||
|
Cookies.get("startDate") ?? "",
|
||||||
|
Cookies.get("endDate") ?? ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Form2;
|
||||||
277
frontend/src/components/Form4.tsx
Normal file
277
frontend/src/components/Form4.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Trash, ArrowLeftRight } from "lucide-react";
|
||||||
|
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 = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
loan_code: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
take_date: string | null;
|
||||||
|
returned_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
loaned_items_id: number[];
|
||||||
|
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) => {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
|
if (!m) return iso;
|
||||||
|
const [, y, M, d, h, min] = m;
|
||||||
|
return `${d}.${M}.${y} ${h}:${min}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchUserLoans(): Promise<Loan[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/userLoans`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch user loans");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data === "No loans found for this user") return [];
|
||||||
|
return Array.isArray(data) ? (data as Loan[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Form4: React.FC = () => {
|
||||||
|
const { data: userLoans = [], isFetching } = useQuery({
|
||||||
|
queryKey: ["userLoans"],
|
||||||
|
queryFn: fetchUserLoans,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (loanID: number) => handleDeleteLoan(loanID),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeMutation = useMutation({
|
||||||
|
mutationFn: (loanID: number) => onTake(loanID),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
|
||||||
|
<p>Keine Ausleihen gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-lg font-semibold tracking-tight text-slate-900">
|
||||||
|
Meine Ausleihen
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<table className="table-auto min-w-full text-sm text-slate-700">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-50">
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Leihcode
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Start
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Ende
|
||||||
|
</th>
|
||||||
|
<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
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Erstellt
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Gegenstände
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-600">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{userLoans.map((loan) => (
|
||||||
|
<tr key={loan.id} className="odd:bg-white even:bg-slate-50">
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
|
{loan.loan_code}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
|
{formatDate(loan.start_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
|
{formatDate(loan.end_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
|
||||||
|
{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 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)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div className="text-slate-900">
|
||||||
|
{Array.isArray(loan.loaned_items_name)
|
||||||
|
? loan.loaned_items_name.join(", ")
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(loan.id)}
|
||||||
|
aria-label="Ausleihe löschen"
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Scroll hint */}
|
||||||
|
<div className="border-t border-gray-100 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<ArrowLeftRight className="h-4 w-4 text-gray-400" />
|
||||||
|
<span>Hinweis: Horizontal scrollen, um alle Spalten zu sehen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Form4;
|
||||||
76
frontend/src/components/Header.tsx
Normal file
76
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { changePW } from "../utils/userHandler";
|
||||||
|
import { myToast } from "../utils/toastify";
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<header className="mb-4 sm:mb-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-slate-900 tracking-tight">
|
||||||
|
Gegenstand ausleihen
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 mt-1 text-sm sm:text-base">
|
||||||
|
Schnell und unkompliziert Equipment reservieren
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Aktionen"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
75
frontend/src/components/LoginForm.tsx
Normal file
75
frontend/src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { loginUser } from "../utils/fetchData";
|
||||||
|
import { myToast } from "../utils/toastify";
|
||||||
|
|
||||||
|
type LoginFormProps = {
|
||||||
|
onLogin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = await loginUser(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
onLogin();
|
||||||
|
} else {
|
||||||
|
myToast("Login failed. Please check your credentials.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
|
||||||
|
<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-slate-900 mb-6 text-center">
|
||||||
|
Login
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
id="username"
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-md shadow hover:bg-indigo-700 transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
17
frontend/src/components/Object.tsx
Normal file
17
frontend/src/components/Object.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type ObjectProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Object: React.FC<ObjectProps> = ({ title, description }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||||
|
<p className="text-xs text-slate-600 line-clamp-2">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Object;
|
||||||
98
frontend/src/components/Sidebar.tsx
Normal file
98
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Object from "./Object";
|
||||||
|
import { MonitorSmartphone } from "lucide-react";
|
||||||
|
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
|
||||||
|
|
||||||
|
const Sidebar: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<any[]>(
|
||||||
|
JSON.parse(localStorage.getItem("allItems") || "[]")
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
|
||||||
|
setItems(next);
|
||||||
|
};
|
||||||
|
handler();
|
||||||
|
window.addEventListener(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 sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<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 min-w-0 flex-1 truncate">
|
||||||
|
<MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
|
||||||
|
<span className="truncate">Geräte</span>
|
||||||
|
</span>
|
||||||
|
{outCount > 0 && (
|
||||||
|
<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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll area */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="flex flex-col gap-3 md:space-y-3">
|
||||||
|
{sorted.map((item: any) => (
|
||||||
|
<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>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* Tailwind (v4) */
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Small touch target improvements */
|
||||||
|
@layer base {
|
||||||
|
html:focus-within {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/src/layout/Layout.tsx
Normal file
36
frontend/src/layout/Layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "../App.css";
|
||||||
|
import Header from "../components/Header";
|
||||||
|
import Sidebar from "../components/Sidebar";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
|
||||||
|
type LayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-slate-50 text-slate-800">
|
||||||
|
{/* Main */}
|
||||||
|
<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-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
|
||||||
|
<div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
29
frontend/src/main.tsx
Normal file
29
frontend/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import { ToastContainer, Flip } from "react-toastify";
|
||||||
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "./utils/queryClient";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={3000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="colored"
|
||||||
|
transition={Flip}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
201
frontend/src/utils/fetchData.ts
Normal file
201
frontend/src/utils/fetchData.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { myToast } from "./toastify";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { getBorrowableItemsAtom } from "../States/Atoms";
|
||||||
|
|
||||||
|
// Event name used to notify the app when the list of items has been updated
|
||||||
|
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
|
||||||
|
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) => {
|
||||||
|
if (!token) return;
|
||||||
|
// First we fetch all items that are potentially available for borrowing
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/items`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
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) {
|
||||||
|
myToast("Failed to fetch items", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem("allItems", JSON.stringify(data));
|
||||||
|
// Notify listeners (e.g., Sidebar) that items have been updated
|
||||||
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
|
} catch (error) {
|
||||||
|
myToast("An error occurred", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all loans
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/loans`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
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) {
|
||||||
|
myToast("Failed to fetch loans!", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem("allLoans", JSON.stringify(data));
|
||||||
|
// Notify listeners (e.g., Sidebar) that loans have been updated
|
||||||
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
|
} catch (error) {
|
||||||
|
myToast("An error occurred", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// get user loans
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/userLoans`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
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) {
|
||||||
|
myToast("Failed to fetch user loans!", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem("userLoans", JSON.stringify(data));
|
||||||
|
// Notify listeners (e.g., Sidebar) that loans have been updated
|
||||||
|
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
|
||||||
|
} catch (error) {
|
||||||
|
myToast("An error occurred", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginUser = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data?.token) {
|
||||||
|
Cookies.set("token", data.token);
|
||||||
|
myToast("Login successful!", "success");
|
||||||
|
fetchAllData(Cookies.get("token"));
|
||||||
|
return { success: true, token: data.token } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false } as const;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false } as const;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBorrowableItems = async () => {
|
||||||
|
const startDate = Cookies.get("startDate");
|
||||||
|
const endDate = Cookies.get("endDate");
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
myToast("Bitte wähle einen Zeitraum aus.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
myToast("Failed to fetch borrowable items", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem("borrowableItems", JSON.stringify(data));
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
|
||||||
|
console.log("Borrowable items fetched successfully");
|
||||||
|
} catch (error) {
|
||||||
|
myToast("An error occurred", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
18
frontend/src/utils/toastify.ts
Normal file
18
frontend/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);
|
||||||
|
};
|
||||||
163
frontend/src/utils/userHandler.ts
Normal file
163
frontend/src/utils/userHandler.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { myToast } from "./toastify";
|
||||||
|
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> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/deleteLoan/${loanID}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
myToast("Fehler beim Löschen der Ausleihe", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = localStorage.getItem("userLoans");
|
||||||
|
let current: Array<{ id: number }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
current = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.filter(
|
||||||
|
(loan) => Number(loan.id) !== Number(loanID)
|
||||||
|
);
|
||||||
|
localStorage.setItem("userLoans", JSON.stringify(updated));
|
||||||
|
|
||||||
|
myToast("Ausleihe erfolgreich gelöscht!", "success");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting loan:", error);
|
||||||
|
myToast("Fehler beim löschen der Ausleihe", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse existing cookie and coerce to numbers
|
||||||
|
let removeArr: number[] = (() => {
|
||||||
|
try {
|
||||||
|
const raw = Cookies.get("removeArr");
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n))
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rawCookies = Cookies.withConverter({
|
||||||
|
write: (value: string) => value, // store raw JSON
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addToRemove = (itemID: number) => {
|
||||||
|
if (!Number.isFinite(itemID)) return;
|
||||||
|
if (!removeArr.includes(itemID)) {
|
||||||
|
removeArr.push(itemID);
|
||||||
|
rawCookies.set("removeArr", JSON.stringify(removeArr));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rmFromRemove = (itemID: number) => {
|
||||||
|
removeArr = removeArr.filter((item) => item !== itemID);
|
||||||
|
rawCookies.set("removeArr", JSON.stringify(removeArr));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLoan = async (startDate: string, endDate: string) => {
|
||||||
|
const items = removeArr;
|
||||||
|
const response = await fetch(`${API_BASE}/api/createLoan`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ items, startDate, endDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
myToast("Fehler beim Erstellen der Ausleihe", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection on success
|
||||||
|
removeArr = [];
|
||||||
|
Cookies.set("removeArr", "[]");
|
||||||
|
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;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user