47 Commits

Author SHA1 Message Date
a932144e94 Update README.md 2025-08-21 19:02:13 +02:00
36ad60b782 Merge branch 'dev' into debian12 2025-08-21 18:55:50 +02:00
7faf95188d added source code button 2025-08-21 18:53:33 +02:00
79df00a17e added footer component 2025-08-21 18:53:18 +02:00
d1625d7e47 refactor help site 2025-08-21 13:00:01 +02:00
a8377e5ec3 added help section 2025-08-21 12:48:03 +02:00
8f1d401aa1 enhance sidebar item descriptions for clarity 2025-08-21 12:44:15 +02:00
226a267ccb updated example response in API documentation to include detailed item properties and entry creation timestamps 2025-08-21 00:38:28 +02:00
fd9496645a added detailed API documentation for retrieving loan details by loan code 2025-08-21 00:36:19 +02:00
33bb64f44b added ToDo.tsx to .gitignore 2025-08-20 18:14:15 +02:00
e4467dba32 Merge branch 'dev' into debian12 2025-08-20 18:10:27 +02:00
2cf82c8dcb fixed bug: error handeling 2025-08-20 18:09:33 +02:00
bcf3cc08bb added improved error handeling 2025-08-20 18:06:59 +02:00
cd2b0e8e42 deleted unused notes 2025-08-20 18:06:40 +02:00
4b79583574 added new api endpoint for getting the loan by the loan code 2025-08-20 17:07:49 +02:00
410923af92 Merge branch 'dev' into debian12 2025-08-20 13:39:19 +02:00
c779a31bfa updated toastify design 2025-08-20 13:38:54 +02:00
24c405386b fixed url bug 2025-08-20 13:21:55 +02:00
d5296bd3fa Merge branch 'dev' into debian12 2025-08-20 13:18:01 +02:00
c33f3e1101 refactor Layout component for improved sidebar responsiveness 2025-08-20 13:17:23 +02:00
67dd74d3d3 refactor Sidebar component layout for improved responsiveness 2025-08-20 13:09:32 +02:00
d82cde55cc deleted unnecessary imports 2025-08-20 13:06:46 +02:00
2b7f6e8e17 fixed sidebar displaying bug 2025-08-20 13:06:22 +02:00
ccceb5840c fixed sidebar display bug 2025-08-20 12:42:29 +02:00
ff219d850b fixed bug: Now you dont have to reload the page twice, after creating a loan 2025-08-20 12:30:37 +02:00
ef19592b32 refactor docs 2025-08-20 01:29:58 +02:00
3ee2f6b670 Merge branch 'dev' into debian12 2025-08-20 01:08:15 +02:00
8291968e7a maked ui simpler 2025-08-20 01:07:00 +02:00
09af4c760c fix: update database connection settings in docker-compose and database service 2025-08-20 00:49:44 +02:00
3fd0fd9584 fix: add borrow_system-internal network to frontend, backend, and mysql services in docker-compose 2025-08-20 00:45:43 +02:00
27984ebac8 added 2025-08-20 00:42:56 +02:00
3d4aab74d5 changed back 2025-08-20 00:30:49 +02:00
4076630eec changed 2025-08-20 00:27:06 +02:00
6025212e93 refactor: remove redundant environment variable validation and connection check in database service
fix: update dependency reference for backend service in docker-compose
2025-08-20 00:25:35 +02:00
de554048eb added tester 2025-08-20 00:20:35 +02:00
e1d79d2c79 fix: update port numbers and API endpoints for consistency across backend and frontend 2025-08-19 23:55:13 +02:00
2480bfab89 feat: update UI components and styles for improved user experience
- Removed obsolete PDF file from the mock directory.
- Updated index.html to change the favicon and title for the application.
- Deleted unused vite.svg file and replaced it with shapes.svg.
- Enhanced App component layout and styling.
- Refined Form1 component with better spacing and updated styles.
- Improved Form2 component to enhance item selection UI and responsiveness.
- Updated Form4 component to improve loan display and interaction.
- Enhanced Header component styling for better visibility.
- Refined LoginForm component for a more modern look.
- Updated Object component styles for better text visibility.
- Improved Sidebar component layout and item display.
- Updated global CSS for better touch target improvements.
- Enhanced Layout component for better responsiveness and structure.
- Updated main.tsx to change toast notification theme.
- Updated tailwind.config.js to include index.html for Tailwind CSS processing.
2025-08-19 23:32:14 +02:00
64bfbecd84 enhance documentation with additional context for item states and API responses 2025-08-19 21:40:28 +02:00
d1494473ef fixed display bug in the table 2025-08-19 21:37:32 +02:00
cbcf282ca3 removed unnesesarry function 2025-08-19 21:26:48 +02:00
c389b38cf5 added new log column "take_date"
Also removed unnesesarry code notes
2025-08-19 21:24:41 +02:00
4080d171cf changed docs accordingly 2025-08-19 21:23:54 +02:00
6d4afa46d7 added more API functions 2025-08-19 21:23:29 +02:00
ffc8fbcefc adjusted data structure 2025-08-19 21:23:15 +02:00
a1435e3280 docs: Remove outdated quick start instructions from README 2025-08-19 19:43:48 +02:00
61c0c8ac96 Overworked docs 2025-08-19 19:42:11 +02:00
6c56c3e46d refactor: Simplify and update backend API documentation for clarity and structure 2025-08-19 19:36:13 +02:00
37 changed files with 946 additions and 659 deletions

4
.gitignore vendored
View File

@@ -111,4 +111,6 @@ backend/public/uploads/
# API keys and secrets (additional protection) # API keys and secrets (additional protection)
config/ config/
secrets/ secrets/
keys/ keys/
ToDo.txt

11
Docs/HELP.md Normal file
View File

@@ -0,0 +1,11 @@
# Hilfe Seite
Hier finden Sie Informationen zur Verwendung des Systems.
## Unerwartete Probleme
Falls unerwartetet Probleme im Web oder im Safe auftreten sollten, können Sie den Support via Teams kontaktieren.
**Kontaktpersonen:**
- Theis Gaedigk (Web & Safe)
- Niklas Brunke (Safe)

View File

@@ -0,0 +1,5 @@
# Backend API Documentation
This document provides an overview of the backend API endpoints and their usage.
To get to that information, go to the `backend_API_docs` directory.

View File

@@ -1,283 +1,306 @@
# Borrow System Backend API # Backend API docs
Base URL: `http://localhost:8002` If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
- App server: [backend/server.js](backend/server.js) On this page you will learn how my API works.
- Auth/JWT: [`authenticate`](backend/services/tokenService.js), [`generateToken`](backend/services/tokenService.js)
- App API (JWT): [backend/routes/api.js](backend/routes/api.js) ## General information
- Admin API (key): [backend/routes/apiV2.js](backend/routes/apiV2.js)
- DB layer: [backend/services/database.js](backend/services/database.js) When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` is for my web frontend, because this file works together with my JWT token service.
- Schema: [backend/scheme.sql](backend/scheme.sql)
**\*But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.**
This is the file that you can use to build an API.
But first you have to get the Admin API key, stored in an `.env` file on my server.
---
## Authentication ## Authentication
Most endpoints under `/api` require a Bearer JWT. All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
1. Login to get a token Example: `/apiV2/items/{ADMIN_ID}`
- POST /api/login
- Body: `{ "username": string, "password": string }`
- Response 200: `{ "message": "Login successful", "token": string }`
- Response 401: `{ "message": "Invalid credentials" }`
Example:
```sh
curl -s -X POST http://localhost:8002/api/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password1"}'
```
2. Use the token for all protected endpoints
- Header: `Authorization: Bearer <token>`
The middleware [`authenticate`](backend/services/tokenService.js) verifies tokens and attaches `req.user = { username, role }`.
Environment:
- SECRET_KEY: HMAC secret for JWT
- DB_HOST, DB_USER, DB_PASSWORD, DB_NAME: MySQL connection
- ADMIN_ID: Admin API key for /apiV2
## Data model (items)
Items (as returned by endpoints) have:
- `id`: number
- `item_name`: string
- `can_borrow_role`: number (minimum role required)
- `inSafe`: 0/1 (not in locker / in locker)
See the full schema in [backend/scheme.sql](backend/scheme.sql).
--- ---
## App API (JWT) — /api ## URL
All routes below require `Authorization: Bearer <token>` unless noted. - The frontend is currently running on `https://insta.the1s.de`.
### GET /api/items - The backend is currently running on `https://backend.insta.the1s.de`.
Returns items filtered by the user role: You can see the status of this and all my other services at `https://status.the1s.de`.
- role == 0: all items ---
- role > 0: items with `can_borrow_role >= role`
Implements: [`getItemsFromDatabase`](backend/services/database.js) ## Current endpoints
Response 200: ### 1. Get All Items
```json **GET** `/apiV2/items/:key`
[
{ "id": 1, "item_name": "Laptop", "can_borrow_role": 1, "inSafe": 1 }, Returns a list of all items and their details.
...
] #### Example Request
```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
``` ```
Example: #### Example Response
```sh ```
curl -s http://localhost:8002/api/items \ {
-H "Authorization: Bearer $TOKEN" "data": [
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 2,
"item_name": "DJI 2er Mikro 1",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 3,
"item_name": "DJI 2er Mikro 2",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 4,
"item_name": "Rode Richt Mikrofon",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 5,
"item_name": "Kamera Stativ",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 6,
"item_name": "SONY Kamera - inkl. Akkus und Objektiv",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 7,
"item_name": "MacBook inkl. Adapter",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 8,
"item_name": "SD Karten",
"can_borrow_role": 3,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 9,
"item_name": "Kameragimbal",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 10,
"item_name": "ATEM MINI PRO",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 11,
"item_name": "Handygimbal",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 12,
"item_name": "Kameralfter",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 13,
"item_name": "Kleine Kamera 1 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 14,
"item_name": "Kleine Kamera 2 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
}
]
}
``` ```
### GET /api/loans Each item has the following properties:
Returns all loans. - `id`: The unique identifier for the item.
- `item_name`: The name of the item.
- `can_borrow_role`: The role ID that is allowed to borrow the item.
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time.
Implements: [`getLoansFromDatabase`](backend/services/database.js) _You also get an http 200 status code._
Response 200: ---
```json ### 2. Change Item Safe State
[
{ **POST** `/apiV2/controlInSafe/:key/:itemId/:state`
"id": 1,
"username": "alice", Updates the `inSafe` state of an item (whether it is in the locker).
"loan_code": 1001,
"start_date": "2025-08-01T09:00:00.000Z", - `state` must be `"1"` (in safe) or `"0"` (not in safe).
"end_date": "2025-08-10T09:00:00.000Z",
#### Example Request
```
POST https://backend.insta.the1s.de/apiV2/controlInSafe/your_admin_key/item_id/new_item_state
```
#### Example Response
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 200 status code._
---
### 3. Set Return Date
**POST** `/apiV2/setReturnDate/:key/:loan_code`
Sets the `returned_date` of a loan to the current server time.
- `loan_code`: The unique code of the loan.
#### Example Request
```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/your_admin_key/your_loan_code
```
#### Example Response
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 200 status code._
---
### 4. Set Take Date
**POST** `/apiV2/setTakeDate/:key/:loan_code`
Sets the `take_date` of a loan to the current server time.
- `loan_code`: The unique code of the loan.
#### Example Request
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/your_admin_key/your_loan_code
```
#### Example Response
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 2xx status code._
---
### 5. Get whole loan by loan code
**POST** `/getLoanByCode/:key/:loan_code`
Retrieves the details of a specific loan by its unique code.
- `loan_code`: The unique code of the loan.
#### Example Request
```
GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
```
#### Example Response
```
{
"data": {
"id": 6,
"username": "theis",
"loan_code": 646473,
"start_date": "2025-08-25T13:23:00.000Z",
"end_date": "2025-08-26T13:23:00.000Z",
"take_date": null,
"returned_date": null, "returned_date": null,
"created_at": "2025-08-01T09:00:00.000Z", "created_at": "2025-08-20T11:23:40.000Z",
"loaned_items_id": [1, 2], "loaned_items_id": [
"loaned_items_name": ["Laptop", "Projector"] 8,
9
],
"loaned_items_name": [
"SD Karten",
"Kameragimbal"
]
} }
]
```
### GET /api/userLoans
Returns loans for the authenticated user.
Implements: [`getUserLoansFromDatabase`](backend/services/database.js)
Response 200:
- On success: `Loan[]`
- If none found: `"No loans found for this user"` (string)
Tip: Treat a non-array response as “no loans”.
### DELETE /api/deleteLoan/:id
Deletes a loan by numeric ID.
Implements: [`deleteLoanFromDatabase`](backend/services/database.js)
- 200: `{ "message": "Loan deleted successfully" }`
- 500: `{ "message": "Failed to delete loan" }`
Example:
```sh
curl -s -X DELETE http://localhost:8002/api/deleteLoan/42 \
-H "Authorization: Bearer $TOKEN"
```
### POST /api/borrowableItems
Returns items available in the given time range (excludes items with overlapping loans). Also enforces role filtering.
Implements: [`getBorrowableItemsFromDatabase`](backend/services/database.js)
Request body:
```json
{ "startDate": "2025-08-01T09:00:00Z", "endDate": "2025-08-02T09:00:00Z" }
```
- 200: `Item[]`
- 400: `{ "message": "startDate and endDate are required" }`
- 500: `{ "message": "Failed to fetch borrowable items" }`
Example:
```sh
curl -s -X POST http://localhost:8002/api/borrowableItems \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"startDate":"2025-08-01T09:00:00Z","endDate":"2025-08-02T09:00:00Z"}'
```
### POST /api/createLoan
Creates a loan for the authenticated user.
Implements: [`createLoanInDatabase`](backend/services/database.js)
Request body:
```json
{
"items": [1, 2, 3], // array of item IDs (required)
"startDate": "2025-08-01T09:00:00Z", // required
"endDate": "2025-08-02T09:00:00Z" // required
} }
``` ```
Notes: _You also get an http 200 status code._
- IDs are coerced to numbers; invalid entries are dropped. If the loan id does not exist, you will receive a 404 status code and an error message.
- Date range must be valid and `startDate < endDate`.
- Overlaps with existing loans cause 409 Conflict.
- On success, returns the generated `loanCode`.
Responses: ```
- 201:
```json
{ {
"message": "Loan created successfully", "message": "Loan not found"
"loanId": 123,
"loanCode": 1007
} }
``` ```
- 400: `{ "message": "Items array is required" | "No valid item IDs provided" | "Invalid date range" | ... }` ---
- 409: `{ "message": "Items not available in the selected period" }`
- 500: `{ "message": "Failed to create loan" }`
Example: ## Error Handling
```sh - `403 Forbidden`: Invalid or missing API key.
curl -s -X POST http://localhost:8002/api/createLoan \ - `400 Bad Request`: Invalid parameters (e.g., wrong state value).
-H "Authorization: Bearer $TOKEN" \ - `500 Internal Server Error`: Database or server error.
-H "Content-Type: application/json" \
-d '{"items":[1,2],"startDate":"2025-08-01T09:00:00Z","endDate":"2025-08-02T09:00:00Z"}'
```
--- ---
## Admin API — /apiV2 If you have questions or want to collaborate, please reach out to me!
These endpoints are protected by a static admin key in the path. Set `ADMIN_ID` in environment. No JWT required.
### GET /apiV2/items/:key
Returns all items (no role filtering).
Implements: [`getItemsFromDatabaseV2`](backend/services/database.js)
- 200: `Item[]`
- 403: `{ "message": "Access denied" }`
Example:
```sh
curl -s http://localhost:8002/apiV2/items/$ADMIN_ID
```
### POST /apiV2/controlInSafe/:key/:itemId/:state
Updates `inSafe` state (0 or 1) for an item by ID.
Implements: [`changeInSafeStateV2`](backend/services/database.js)
- `state`: `"0"` or `"1"`
- 200: `{ "message": "Item state updated successfully" }`
- 400: `{ "message": "Invalid state value" }`
- 403: `{ "message": "Access denied" }`
- 500: `{ "message": "Failed to update item state" }`
Example:
```sh
curl -s -X POST http://localhost:8002/apiV2/controlInSafe/$ADMIN_ID/5/0
```
---
## Error handling summary
- 400 Bad Request: invalid payloads or missing fields
- 401 Unauthorized: missing/invalid JWT (for `/api` routes)
- 403 Forbidden: wrong admin key (for `/apiV2` routes)
- 409 Conflict: loan overlaps with selected period
- 500 Internal Server Error: unexpected server/database errors
---
## Running locally
With Docker Compose: [docker-compose.yml](docker-compose.yml)
- Backend: http://localhost:8002 (mounted from [backend](backend))
- MySQL: root password from `.env` as `DB_PASSWORD`, port 3309 on host
- Seed schema/data: import [backend/scheme.sql](backend/scheme.sql) into the DB
Environment required by backend:
- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`
- `SECRET_KEY`
- `ADMIN_ID`
---
References:
- App routes: [backend/routes/api.js](backend/routes/api.js)
- [`loginFunc`](backend/services/database.js), [`getItemsFromDatabase`](backend/services/database.js), [`getLoansFromDatabase`](backend/services/database.js), [`getUserLoansFromDatabase`](backend/services/database.js), [`deleteLoanFromDatabase`](backend/services/database.js), [`getBorrowableItemsFromDatabase`](backend/services/database.js), [`createLoanInDatabase`](backend/services/database.js)
- Admin routes: [backend/routes/apiV2.js](backend/routes/apiV2.js)
- Auth: [`authenticate`](backend/services/tokenService.js),

Binary file not shown.

View File

@@ -0,0 +1,7 @@
# Borrow System
**You have reached the `debian12` branch.**
Here you will find the source code of exactly the application that I have hosted.
The main branch or the branch that I am developing on, is the `dev` branch.

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . . COPY . .
EXPOSE 8002 EXPOSE 8102
CMD ["npm", "start"] CMD ["npm", "start"]

View File

@@ -11,7 +11,6 @@ import {
import { authenticate, generateToken } from "../services/tokenService.js"; import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router(); const router = express.Router();
// Example endpoint
router.post("/login", async (req, res) => { router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password); const result = await loginFunc(req.body.username, req.body.password);
if (result.success) { if (result.success) {

View File

@@ -3,16 +3,20 @@ import dotenv from "dotenv";
import { import {
getItemsFromDatabaseV2, getItemsFromDatabaseV2,
changeInSafeStateV2, changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
getLoanByCodeV2,
} from "../services/database.js"; } from "../services/database.js";
dotenv.config(); dotenv.config();
const router = express.Router(); const router = express.Router();
// Route for API to get ALL items from the database
router.get("/items/:key", async (req, res) => { router.get("/items/:key", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) { if (req.params.key === process.env.ADMIN_ID) {
const result = await getItemsFromDatabaseV2(); const result = await getItemsFromDatabaseV2();
if (result.success) { if (result.success) {
res.status(200).json(result.data); res.status(200).json({ data: result.data });
} else { } else {
res.status(500).json({ message: "Failed to fetch items" }); res.status(500).json({ message: "Failed to fetch items" });
} }
@@ -21,6 +25,7 @@ router.get("/items/:key", async (req, res) => {
} }
}); });
// Route for API to control the position of an item
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => { router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) { if (req.params.key === process.env.ADMIN_ID) {
const itemId = req.params.itemId; const itemId = req.params.itemId;
@@ -28,7 +33,7 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
if (state === "1" || state === "0") { if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state); const result = await changeInSafeStateV2(itemId, state);
if (result.success) { if (result.success) {
res.status(200).json({ message: "Item state updated successfully" }); res.status(200).json({ data: result.data });
} else { } else {
res.status(500).json({ message: "Failed to update item state" }); res.status(500).json({ message: "Failed to update item state" });
} }
@@ -40,4 +45,49 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
} }
}); });
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(404).json({ message: "Loan not found" });
}
}
});
// Route for API to set the return date
router.post("/setReturnDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
// Route for API to set the take away date
router.post("/setTakeDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
export default router; export default router;

View File

@@ -7,6 +7,7 @@ CREATE TABLE `users` (
`username` varchar(100) NOT NULL, `username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL, `password` varchar(255) NOT NULL,
`role` int DEFAULT NULL, `role` int DEFAULT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) UNIQUE KEY `username` (`username`)
); );
@@ -17,6 +18,7 @@ CREATE TABLE `loans` (
`loan_code` int NOT NULL, `loan_code` int NOT NULL,
`start_date` timestamp NOT NULL, `start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL, `end_date` timestamp NOT NULL,
`take_date` timestamp NULL DEFAULT NULL,
`returned_date` timestamp NULL DEFAULT NULL, `returned_date` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'), `loaned_items_id` json NOT NULL DEFAULT ('[]'),
@@ -30,6 +32,7 @@ CREATE TABLE `items` (
`item_name` varchar(255) NOT NULL, `item_name` varchar(255) NOT NULL,
`can_borrow_role` INT NOT NULL, `can_borrow_role` INT NOT NULL,
`inSafe` tinyint(1) NOT NULL DEFAULT '1', `inSafe` tinyint(1) NOT NULL DEFAULT '1',
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `item_name` (`item_name`) UNIQUE KEY `item_name` (`item_name`)
); );
@@ -38,100 +41,40 @@ CREATE TABLE `lockers` (
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`item` varchar(255) NOT NULL, `item` varchar(255) NOT NULL,
`locker_number` int NOT NULL, `locker_number` int NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `item` (`item`), UNIQUE KEY `item` (`item`),
UNIQUE KEY `locker_number` (`locker_number`) UNIQUE KEY `locker_number` (`locker_number`)
); );
-- Mock data for users
INSERT INTO `users` (`username`, `password`, `role`) VALUES
('alice', 'password1', 1),
('bob', 'password2', 2),
('carol', 'password3', 1),
('dave', 'password4', 3),
('eve', 'password5', 2),
('frank', 'password6', 1),
('grace', 'password7', 2),
('heidi', 'password8', 3),
('ivan', 'password9', 1),
('judy', 'password10', 2),
('mallory', 'password11', 1),
('oscar', 'password12', 3),
('peggy', 'password13', 2),
('trent', 'password14', 1),
('victor', 'password15', 2),
('wendy', 'password16', 3),
('zoe', 'password17', 1),
('quinn', 'password18', 2),
('ruth', 'password19', 1),
('sam', 'password20', 3);
-- Mock data for loans
INSERT INTO `loans` (`username`, `loan_code`, `start_date`, `end_date`, `returned_date`, `loaned_items_id`, `loaned_items_name`)
VALUES
('alice', 1001, '2025-08-01 09:00:00', '2025-08-10 09:00:00', NULL, '[1,2]', '["Laptop","Projector"]'),
('bob', 1002, '2025-08-02 10:00:00', '2025-08-12 10:00:00', NULL, '[3]', '["Tablet"]'),
('carol', 1003, '2025-08-03 11:00:00', '2025-08-13 11:00:00', NULL, '[4,5]', '["Camera","Tripod"]'),
('dave', 1004, '2025-08-04 12:00:00', '2025-08-14 12:00:00', NULL, '[6]', '["Microphone"]'),
('eve', 1005, '2025-08-05 13:00:00', '2025-08-15 13:00:00', NULL, '[7,8]', '["Speaker","Monitor"]'),
('frank', 1006, '2025-08-06 14:00:00', '2025-08-16 14:00:00', NULL, '[9]', '["Keyboard"]'),
('grace', 1007, '2025-08-07 15:00:00', '2025-08-17 15:00:00', NULL, '[10,11]', '["Mouse","Printer"]'),
('heidi', 1008, '2025-08-08 16:00:00', '2025-08-18 16:00:00', NULL, '[12]', '["Scanner"]'),
('ivan', 1009, '2025-08-09 17:00:00', '2025-08-19 17:00:00', NULL, '[13,14]', '["Router","Switch"]'),
('judy', 1010, '2025-08-10 18:00:00', '2025-08-20 18:00:00', NULL, '[15]', '["Projector"]'),
('mallory', 1011, '2025-08-11 09:00:00', '2025-08-21 09:00:00', NULL, '[16,17]', '["Laptop","Tablet"]'),
('oscar', 1012, '2025-08-12 10:00:00', '2025-08-22 10:00:00', NULL, '[18]', '["Camera"]'),
('peggy', 1013, '2025-08-13 11:00:00', '2025-08-23 11:00:00', NULL, '[19,20]', '["Tripod","Microphone"]'),
('trent', 1014, '2025-08-14 12:00:00', '2025-08-24 12:00:00', NULL, '[1]', '["Laptop"]'),
('victor', 1015, '2025-08-15 13:00:00', '2025-08-25 13:00:00', NULL, '[2,3]', '["Projector","Tablet"]'),
('wendy', 1016, '2025-08-16 14:00:00', '2025-08-26 14:00:00', NULL, '[4]', '["Camera"]'),
('zoe', 1017, '2025-08-17 15:00:00', '2025-08-27 15:00:00', NULL, '[5,6]', '["Tripod","Microphone"]'),
('quinn', 1018, '2025-08-18 16:00:00', '2025-08-28 16:00:00', NULL, '[7]', '["Speaker"]'),
('ruth', 1019, '2025-08-19 17:00:00', '2025-08-29 17:00:00', NULL, '[8,9]', '["Monitor","Keyboard"]'),
('sam', 1020, '2025-08-20 18:00:00', '2025-08-30 18:00:00', NULL, '[10]', '["Mouse"]');
-- Mock data for items
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('Laptop', 1, 1), ('DJI 1er Mikro', 4, 1),
('Projector', 2, 1), ('DJI 2er Mikro 1', 4, 1),
('Tablet', 1, 1), ('DJI 2er Mikro 2', 4, 1),
('Camera', 2, 1), ('Rode Richt Mikrofon', 2, 1),
('Tripod', 1, 1), ('Kamera Stativ', 1, 0),
('Microphone', 3, 1), ('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
('Speaker', 2, 1), ('MacBook inkl. Adapter', 2, 0),
('Monitor', 1, 1), ('SD Karten', 3, 0),
('Keyboard', 2, 1), ('Kameragimbal', 1, 0),
('Mouse', 1, 1), ('ATEM MINI PRO', 1, 1),
('Printer', 3, 1), ('Handygimbal', 4, 0),
('Scanner', 2, 1), ('Kameralüfter', 1, 1),
('Router', 1, 1), ('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
('Switch', 2, 1), ('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
('Charger', 1, 1),
('USB Cable', 2, 1),
('HDMI Cable', 1, 1),
('Webcam', 3, 1),
('Headphones', 2, 1),
('Smartphone', 1, 1);
-- Mock data for lockers
INSERT INTO `lockers` (`item`, `locker_number`) VALUES INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('Laptop', 101), ('DJI 1er Mikro', 1),
('Projector', 102), ('DJI 2er Mikro 1', 2),
('Tablet', 103), ('DJI 2er Mikro 2', 3),
('Camera', 104), ('Rode Richt Mikrofon', 4),
('Tripod', 105), ('Kamera Stativ', 5),
('Microphone', 106), ('SONY Kamera - inkl. Akkus und Objektiv', 6),
('Speaker', 107), ('MacBook inkl. Adapter', 7),
('Monitor', 108), ('SD Karten', 8),
('Keyboard', 109), ('Kameragimbal', 9),
('Mouse', 110), ('ATEM MINI PRO', 10),
('Printer', 111), ('Handygimbal', 11),
('Scanner', 112), ('Kameralüfter', 12),
('Router', 113), ('Kleine Kamera 1 - inkl. Objektiv', 13),
('Switch', 114), ('Kleine Kamera 2 - inkl. Objektiv', 14);
('Charger', 115),
('USB Cable', 116),
('HDMI Cable', 117),
('Webcam', 118),
('Headphones', 119),
('Smartphone', 120);

View File

@@ -5,7 +5,7 @@ import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js"; import apiRouterV2 from "./routes/apiV2.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8002; const port = 8102;
app.use(cors()); app.use(cors());
// Increase body size limits to support large CSV JSON payloads // Increase body size limits to support large CSV JSON payloads

View File

@@ -8,6 +8,7 @@ const pool = mysql
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: process.env.DB_PORT,
}) })
.promise(); .promise();
@@ -28,6 +29,17 @@ export const getItemsFromDatabaseV2 = async () => {
return { success: false }; return { success: false };
}; };
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE loan_code = ?;",
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId, state) => { export const changeInSafeStateV2 = async (itemId, state) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET inSafe = ? WHERE id = ?", "UPDATE items SET inSafe = ? WHERE id = ?",
@@ -39,6 +51,28 @@ export const changeInSafeStateV2 = async (itemId, state) => {
return { success: false }; return { success: false };
}; };
export const setReturnDateV2 = async (loanCode) => {
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getItemsFromDatabase = async (role) => { export const getItemsFromDatabase = async (role) => {
const sql = const sql =
role == 0 role == 0

View File

@@ -1,23 +1,30 @@
services: services:
# borrow_system-frontend: borrow_system-frontend:
# container_name: borrow_system-frontend container_name: borrow_system-frontend
# build: ./frontend build: ./frontend
# ports: ports:
# - "8001:8001" - "8101:8101"
# environment: networks:
# - CHOKIDAR_USEPOLLING=true - proxynet
# volumes: - borrow_system-internal
# - ./frontend:/app environment:
# - /app/node_modules - CHOKIDAR_USEPOLLING=true
# restart: unless-stopped volumes:
- ./frontend:/app
- /app/node_modules
restart: unless-stopped
borrow_system-backend: borrow_system-backend:
container_name: borrow_system-backend container_name: borrow_system-backend
build: ./backend build: ./backend
ports: ports:
- "8002:8002" - "8102:8102"
networks:
- proxynet
- borrow_system-internal
environment: environment:
DB_HOST: mysql DB_HOST: mysql
DB_PORT: 3306
DB_USER: root DB_USER: root
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: borrow_system DB_NAME: borrow_system
@@ -38,6 +45,14 @@ services:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
ports: ports:
- "3309:3306" - "3309:3306"
networks:
- borrow_system-internal
volumes: volumes:
mysql-data: mysql-data:
networks:
proxynet:
external: true
borrow_system-internal:
external: false

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . . COPY . .
EXPOSE 8001 EXPOSE 8101
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]

View File

@@ -1,10 +1,10 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/shapes.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Ausleihsystem</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0", "@tanstack/react-query": "^5.85.5",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -1836,9 +1836,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.85.3", "version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==", "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1846,12 +1846,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.85.3", "version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz",
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==", "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.85.3" "@tanstack/query-core": "5.85.5"
}, },
"funding": { "funding": {
"type": "github", "type": "github",

View File

@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0", "@tanstack/react-query": "^5.85.5",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -41,4 +41,4 @@
"typescript-eslint": "^8.39.0", "typescript-eslint": "^8.39.0",
"vite": "^7.1.0" "vite": "^7.1.0"
} }
} }

View 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

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -6,7 +6,11 @@ import Form2 from "./components/Form2";
import Form4 from "./components/Form4"; import Form4 from "./components/Form4";
import LoginForm from "./components/LoginForm"; import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { fetchAllData, ALL_ITEMS_UPDATED_EVENT } from "./utils/fetchData"; import {
fetchAllData,
ALL_ITEMS_UPDATED_EVENT,
AUTH_LOGOUT_EVENT,
} from "./utils/fetchData";
import { myToast } from "./utils/toastify"; import { myToast } from "./utils/toastify";
function App() { function App() {
@@ -18,18 +22,23 @@ function App() {
setIsLoggedIn(true); setIsLoggedIn(true);
fetchAllData(token); fetchAllData(token);
} }
localStorage.setItem("borrowableItems", JSON.stringify([])); localStorage.setItem("borrowableItems", JSON.stringify([]));
}, []); }, []);
// Mock flow without real logic: show the three sections stacked for design preview useEffect(() => {
const onAuthLogout = () => {
setIsLoggedIn(false);
};
window.addEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
return () => window.removeEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
}, []);
const handleLogout = () => { const handleLogout = () => {
Cookies.remove("token"); Cookies.remove("token");
localStorage.removeItem("allItems"); localStorage.removeItem("allItems");
localStorage.removeItem("allLoans"); localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans"); localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems"); localStorage.removeItem("borrowableItems");
// Let listeners refresh from empty state
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT)); window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success"); myToast("Logged out successfully!", "success");
setIsLoggedIn(false); setIsLoggedIn(false);
@@ -37,11 +46,11 @@ function App() {
return isLoggedIn ? ( return isLoggedIn ? (
<Layout onLogout={handleLogout}> <Layout onLogout={handleLogout}>
<div className="space-y-10"> <div className="space-y-6">
<Form1 /> <Form1 />
<div className="h-px bg-blue-100" /> <div className="h-px bg-slate-200" />
<Form2 /> <Form2 />
<div className="h-px bg-blue-100" /> <div className="h-px bg-slate-200" />
<Form4 /> <Form4 />
</div> </div>
</Layout> </Layout>

View 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;

View File

@@ -4,10 +4,12 @@ import { getBorrowableItems } from "../utils/fetchData";
const Form1: React.FC = () => { const Form1: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<h2 className="text-xl font-bold text-blue-700">1. Zeitraum wählen</h2> <h2 className="text-lg sm:text-xl font-bold text-slate-900">
1. Zeitraum wählen
</h2>
<form <form
className="space-y-4" className="space-y-3"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const form = e.currentTarget as HTMLFormElement; const form = e.currentTarget as HTMLFormElement;
@@ -17,40 +19,41 @@ const Form1: React.FC = () => {
Cookies.set("startDate", start); Cookies.set("startDate", start);
Cookies.set("endDate", end); Cookies.set("endDate", end);
getBorrowableItems(); getBorrowableItems();
console.log("Zeitraum erfolgreich gesetzt!");
}} }}
> >
<div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label <div>
htmlFor="startDate" <label
className="block text-sm font-medium text-blue-800 mb-1" htmlFor="startDate"
> className="block text-sm font-medium text-slate-700 mb-1"
Start >
</label> Start
<input </label>
type="datetime-local" <input
id="startDate" type="datetime-local"
name="startDate" id="startDate"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70" name="startDate"
/> className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
</div> />
<div> </div>
<label <div>
htmlFor="endDate" <label
className="block text-sm font-medium text-blue-800 mb-1" htmlFor="endDate"
> className="block text-sm font-medium text-slate-700 mb-1"
Ende >
</label> Ende
<input </label>
type="datetime-local" <input
id="endDate" type="datetime-local"
name="endDate" id="endDate"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70" name="endDate"
/> className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
/>
</div>
</div> </div>
<button <button
type="submit" type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition" className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
> >
Verfügbare Gegenstände anzeigen Verfügbare Gegenstände anzeigen
</button> </button>

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { createLoan } from "../utils/userHandler"; import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
import { addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData"; import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem { interface BorrowItem {
@@ -13,14 +12,6 @@ interface BorrowItem {
const LOCAL_STORAGE_KEY = "borrowableItems"; const LOCAL_STORAGE_KEY = "borrowableItems";
// Einfache Type-Guard/Validierung
const isBorrowItem = (v: any): v is BorrowItem =>
v &&
typeof v.id === "number" &&
(typeof v.item_name === "string" || typeof v.name === "string") &&
(typeof v.can_borrow_role === "string" || typeof v.role === "string");
// Helfer: unterschiedliche Server-Shapes normalisieren
function normalizeBorrowable(data: any): BorrowItem[] { function normalizeBorrowable(data: any): BorrowItem[] {
const rawArr = Array.isArray(data) const rawArr = Array.isArray(data)
? data ? data
@@ -52,7 +43,6 @@ function normalizeBorrowable(data: any): BorrowItem[] {
.filter(Boolean) as BorrowItem[]; .filter(Boolean) as BorrowItem[];
} }
// Hook, der automatisch aus dem Local Storage liest und auf Änderungen hört
function useBorrowableItems() { function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]); const [items, setItems] = React.useState<BorrowItem[]>([]);
@@ -68,16 +58,13 @@ function useBorrowableItems() {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
// Initial read
readFromStorage(); readFromStorage();
// Cross-tab updates
const onStorage = (e: StorageEvent) => { const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage(); if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
}; };
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
// Same-tab updates via Custom Event
const onBorrowableUpdated = () => readFromStorage(); const onBorrowableUpdated = () => readFromStorage();
window.addEventListener( window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT, BORROWABLE_ITEMS_UPDATED_EVENT,
@@ -100,58 +87,85 @@ const Form2: React.FC = () => {
const items = useBorrowableItems(); const items = useBorrowableItems();
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<h2 className="text-xl font-bold text-blue-700"> <h2 className="text-lg sm:text-xl font-bold text-slate-900">
2. Gegenstand auswählen 2. Gegenstand auswählen
</h2> </h2>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="text-red-600 font-medium text-center bg-red-50 border border-red-200 rounded-xl p-4"> <div className="text-slate-700 text-center bg-slate-100 border border-slate-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum. Keine Gegenstände verfügbar für diesen Zeitraum.
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-blue-100 shadow-sm bg-white/80"> <>
<table className="min-w-full divide-y divide-blue-100"> {/* Mobile: card list */}
<thead className="bg-blue-50/60"> <div className="sm:hidden space-y-2">
<tr> {items.map((item) => (
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700"> <label
Gegenstand key={item.id}
</th> htmlFor={`item-${item.id}`}
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700"> className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-200 bg-white shadow-sm"
<input type="checkbox" className="invisible" /> >
</th> <div className="min-w-0">
</tr> <div className="text-sm font-medium text-slate-900 truncate">
</thead>
<tbody className="divide-y divide-blue-50">
{items.map((item) => (
<tr
key={item.id}
className="hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-2 text-sm font-medium text-blue-900">
{item.item_name} {item.item_name}
</td> </div>
<td className="px-4 py-2 text-sm text-blue-700"> <div className="text-xs text-slate-500">
<input {item.inSafe ? "Verfügbar" : "Nicht im Schließfach"}
type="checkbox" </div>
onChange={(e) => { </div>
if (e.target.checked) { <input
addToRemove(item.id); type="checkbox"
} else { id={`item-${item.id}`}
rmFromRemove(item.id); onChange={(e) => {
} if (e.target.checked) addToRemove(item.id);
}} else rmFromRemove(item.id);
id={`item-${item.id}`} }}
/> className="h-5 w-5 accent-indigo-600"
</td> />
</label>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-slate-200 shadow-sm bg-white">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
Gegenstand
</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
<input type="checkbox" className="invisible" />
</th>
</tr> </tr>
))} </thead>
</tbody> <tbody className="divide-y divide-slate-100">
</table> {items.map((item) => (
</div> <tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-sm font-medium text-slate-900">
{item.item_name}
</td>
<td className="px-4 py-2 text-sm text-slate-700 text-right">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
id={`item-${item.id}`}
className="h-4 w-4 accent-indigo-600"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)} )}
<div className="flex flex-col sm:flex-row gap-3 pt-2"> <div className="flex flex-col sm:flex-row gap-3 pt-1">
<button <button
onClick={() => { onClick={() => {
createLoan( createLoan(
@@ -160,7 +174,7 @@ const Form2: React.FC = () => {
); );
}} }}
type="button" type="button"
className="flex-1 sm:flex-none sm:w-40 bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition" className="w-full sm:w-44 bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
> >
Ausleihen Ausleihen
</button> </button>

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { Trash, ArrowLeftRight } from "lucide-react"; import { Trash, ArrowLeftRight } from "lucide-react";
import { handleDeleteLoan } from "../utils/userHandler"; import { handleDeleteLoan } from "../utils/userHandler";
import { useMutation, useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { queryClient } from "../utils/queryClient";
type Loan = { type Loan = {
id: number; id: number;
@@ -8,6 +11,7 @@ type Loan = {
loan_code: number; loan_code: number;
start_date: string; start_date: string;
end_date: string; end_date: string;
take_date: string | null;
returned_date: string | null; returned_date: string | null;
created_at: string; created_at: string;
loaned_items_id: number[]; loaned_items_id: number[];
@@ -21,45 +25,43 @@ const formatDate = (iso: string | null) => {
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }); return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}; };
const readUserLoansFromStorage = (): Loan[] => { async function fetchUserLoans(): Promise<Loan[]> {
const raw = localStorage.getItem("userLoans"); const res = await fetch("https://backend.insta.the1s.de/api/userLoans", {
if (!raw || raw === '"No loans found for this user"') return []; method: "GET",
try { headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
const parsed = JSON.parse(raw); });
return Array.isArray(parsed) ? (parsed as Loan[]) : []; if (!res.ok) throw new Error("Failed to fetch user loans");
} catch { const data = await res.json();
return []; if (data === "No loans found for this user") return [];
} return Array.isArray(data) ? (data as Loan[]) : [];
}; }
const Form4: React.FC = () => { const Form4: React.FC = () => {
const [userLoans, setUserLoans] = useState<Loan[]>(() => const { data: userLoans = [], isFetching } = useQuery({
readUserLoansFromStorage() queryKey: ["userLoans"],
); queryFn: fetchUserLoans,
});
// Keep in sync if localStorage changes (e.g., other tabs or parts of the app) const deleteMutation = useMutation({
useEffect(() => { mutationFn: (loanID: number) => handleDeleteLoan(loanID),
const onStorage = (e: StorageEvent) => { onSuccess: () => {
if (e.key === "userLoans") { queryClient.invalidateQueries({ queryKey: ["userLoans"] });
setUserLoans(readUserLoansFromStorage()); },
} });
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const onDelete = async (loanID: number) => { const onDelete = (loanID: number) => deleteMutation.mutate(loanID);
const ok = await handleDeleteLoan(loanID);
if (ok) { if (isFetching) {
setUserLoans((prev) => return (
prev.filter((l) => Number(l.id) !== Number(loanID)) <div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
); <p>Lade Ausleihen</p>
} </div>
}; );
}
if (userLoans.length === 0) { if (userLoans.length === 0) {
return ( return (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-center text-gray-600 shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
<p>Keine Ausleihen gefunden.</p> <p>Keine Ausleihen gefunden.</p>
</div> </div>
); );
@@ -67,65 +69,117 @@ const Form4: React.FC = () => {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-lg font-semibold tracking-tight text-gray-900"> <p className="text-lg font-semibold tracking-tight text-slate-900">
Meine Ausleihen Meine Ausleihen
</p> </p>
<p className="text-sm text-gray-600"> <p className="text-sm text-slate-600">
Wenn du eine Ausleihe ändern oder löschen möchtest, klicke auf das Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
Papierkorb-Symbol.
</p> </p>
<div className="rounded-xl border border-gray-200 bg-white shadow-sm"> {/* Mobile: cards */}
<div className="space-y-2 sm:hidden">
{userLoans.map((loan) => (
<div
key={loan.id}
className="rounded-xl border border-slate-200 bg-white p-3 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">
Leihcode: <span className="font-mono">{loan.loan_code}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-700">
<div>
<span className="text-slate-500">Start:</span>{" "}
{formatDate(loan.start_date)}
</div>
<div>
<span className="text-slate-500">Ende:</span>{" "}
{formatDate(loan.end_date)}
</div>
<div>
<span className="text-slate-500">Abgeholt:</span>{" "}
{formatDate(loan.take_date)}
</div>
<div>
<span className="text-slate-500">Zurück:</span>{" "}
{formatDate(loan.returned_date)}
</div>
</div>
<div className="mt-2 text-xs text-slate-700">
<span className="text-slate-500">Gegenstände:</span>{" "}
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</div>
</div>
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="flex items-center justify-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-5 w-5" />
</button>
</div>
</div>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm text-gray-700"> <table className="table-auto min-w-full text-sm text-slate-700">
<thead className="sticky top-0 z-10 bg-gray-50"> <thead className="sticky top-0 z-10 bg-slate-50">
<tr className="border-b border-gray-200"> <tr className="border-b border-slate-200">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Leihcode Leihcode
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Start Start
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Ende Ende
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Abgeholt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Zurückgegeben Zurückgegeben
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Erstellt Erstellt
</th> </th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Gegenstände Gegenstände
</th> </th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-600"> <th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-600">
Aktionen Aktionen
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-slate-100">
{userLoans.map((loan) => ( {userLoans.map((loan) => (
<tr <tr key={loan.id} className="odd:bg-white even:bg-slate-50">
key={loan.id} <td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100/60 transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{loan.loan_code} {loan.loan_code}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900"> <td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.start_date)} {formatDate(loan.start_date)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900"> <td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.end_date)} {formatDate(loan.end_date)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900"> <td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.take_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.returned_date)} {formatDate(loan.returned_date)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900"> <td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.created_at)} {formatDate(loan.created_at)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3 whitespace-nowrap">
<div className="max-w-[22rem] truncate text-gray-900"> <div className="text-slate-900">
{Array.isArray(loan.loaned_items_name) {Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ") ? loan.loaned_items_name.join(", ")
: "-"} : "-"}
@@ -135,7 +189,7 @@ const Form4: React.FC = () => {
<button <button
onClick={() => onDelete(loan.id)} onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen" aria-label="Ausleihe löschen"
className="inline-flex items-center rounded-md p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30" className="inline-flex items-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
> >
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
</button> </button>
@@ -145,7 +199,6 @@ const Form4: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Scroll hint */} {/* Scroll hint */}
<div className="border-t border-gray-100 px-4 py-2"> <div className="border-t border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-gray-500">

View File

@@ -6,20 +6,34 @@ type HeaderProps = {
const Header: React.FC<HeaderProps> = ({ onLogout }) => { const Header: React.FC<HeaderProps> = ({ onLogout }) => {
return ( return (
<header className="mb-6 md:mb-10"> <header className="mb-4 sm:mb-6">
<h1 className="text-3xl md:text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm"> <div className="flex items-start justify-between gap-3">
Gegenstand ausleihen <div className="min-w-0">
</h1> <h1 className="text-2xl sm:text-3xl font-extrabold text-slate-900 tracking-tight">
<p className="text-blue-500 mt-1 md:mt-2 text-base md:text-lg font-medium"> Gegenstand ausleihen
Schnell und unkompliziert Equipment reservieren </h1>
</p> <p className="text-slate-600 mt-1 text-sm sm:text-base">
<button Schnell und unkompliziert Equipment reservieren
type="button" </p>
onClick={onLogout} </div>
className="text-blue-500 hover:underline" <button
> type="button"
Logout onClick={onLogout}
</button> className="h-9 px-3 rounded-md border border-slate-300 text-slate-700 hover:bg-slate-100 transition"
>
Logout
</button>
<a href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/src/branch/dev/Docs/HELP.md">
<button className="h-9 px-3 rounded-md border border-slate-300 text-slate-700 hover:bg-slate-100 transition">
Hilfe
</button>
</a>
<a href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system">
<button className="h-9 px-3 rounded-md border border-slate-300 text-slate-700 hover:bg-slate-100 transition">
Source Code
</button>
</a>
</div>
</header> </header>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import Footer from "./Footer";
import { useState } from "react"; import { useState } from "react";
import { loginUser } from "../utils/fetchData"; import { loginUser } from "../utils/fetchData";
import { myToast } from "../utils/toastify"; import { myToast } from "../utils/toastify";
@@ -22,16 +23,16 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
}; };
return ( return (
<div className="bg-blue-950 min-h-screen"> <div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
<div className="max-w-sm mx-auto mt-20 bg-white rounded-xl shadow-lg p-8 border border-blue-100"> <div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-6 sm:p-8 border border-slate-200">
<h2 className="text-2xl font-bold text-blue-700 mb-6 text-center"> <h2 className="text-2xl font-bold text-slate-900 mb-6 text-center">
Login Login
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label
htmlFor="username" htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-slate-700 mb-1"
> >
Username Username
</label> </label>
@@ -39,14 +40,14 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
type="text" type="text"
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
id="username" id="username"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 px-3 py-2" className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required required
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-slate-700 mb-1"
> >
Password Password
</label> </label>
@@ -54,18 +55,19 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
type="password" type="password"
id="password" id="password"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 px-3 py-2" className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required required
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="w-full bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow hover:bg-blue-700 transition" className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-md shadow hover:bg-indigo-700 transition"
> >
Login Login
</button> </button>
</form> </form>
</div> </div>
<Footer />
</div> </div>
); );
}; };

View File

@@ -7,9 +7,9 @@ type ObjectProps = {
const Object: React.FC<ObjectProps> = ({ title, description }) => { const Object: React.FC<ObjectProps> = ({ title, description }) => {
return ( return (
<div> <div className="min-w-0">
<h3 className="text-sm font-semibold text-blue-800">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="text-xs text-blue-500/80 line-clamp-2">{description}</p> <p className="text-xs text-slate-600 line-clamp-2">{description}</p>
</div> </div>
); );
}; };

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Object from "./Object"; import Object from "./Object";
import { MonitorSmartphone } from "lucide-react";
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData"; import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
const Sidebar: React.FC = () => { const Sidebar: React.FC = () => {
@@ -12,82 +13,83 @@ const Sidebar: React.FC = () => {
const next = JSON.parse(localStorage.getItem("allItems") || "[]"); const next = JSON.parse(localStorage.getItem("allItems") || "[]");
setItems(next); setItems(next);
}; };
// Update immediately in case data changed before this mounted
handler(); handler();
window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler); window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler); return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
}, []); }, []);
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0); const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe)); // außerhalb zuerst const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
return ( return (
<aside className="w-full md:w-80 md:h-screen md:sticky md:top-0 overflow-y-auto bg-white/90 backdrop-blur md:border-r border-blue-100 shadow-xl flex flex-col p-6"> <aside className="w-full md:w-72 md:h-full flex flex-col rounded-2xl pt-0 px-3 pb-3 sm:pt-0 sm:px-4 sm:pb-4 bg-gradient-to-b from-white to-slate-50 ring-1 ring-slate-200/70 shadow-md overflow-hidden">
<h2 className="text-2xl font-extrabold mb-4 text-blue-700 tracking-tight flex items-center justify-between"> <div className="sticky top-0 z-10 -mx-3 sm:-mx-4 px-3 sm:px-4 py-2.5 bg-white/85 backdrop-blur supports-[backdrop-filter]:backdrop-blur border-b border-slate-200/70 text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between gap-2 rounded-t-2xl">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 min-w-0 flex-1 truncate">
<svg <MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
className="w-6 h-6 text-blue-500" <span className="truncate">Geräte</span>
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
/>
</svg>
Geräte Übersicht
</span> </span>
{outCount > 0 && ( {outCount > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700"> <span className="inline-flex items-center gap-1 whitespace-nowrap tabular-nums text-[10px] sm:text-xs px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 ring-1 ring-amber-200/70 shadow-sm font-medium">
{outCount} außerhalb {outCount} außerhalb
</span> </span>
)} )}
</h2>
<div className="space-y-4 flex-1 pr-1">
{sorted.map((item: any) => (
<div
key={item.item_name}
className={`bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition ${
item.inSafe ? "" : "ring-1 ring-red-200"
}`}
>
<div className="flex items-start gap-3">
<span className="relative mt-1 inline-flex" aria-hidden="true">
{!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)}
<span
className={`inline-block w-3 h-3 rounded-full ${
item.inSafe ? "bg-green-400" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</div>
</div>
))}
</div> </div>
<div className="mt-6 text-xs text-gray-400 flex items-center gap-4"> {/* Scroll area */}
<span className="inline-block w-3 h-3 bg-green-400 rounded-full"></span> <div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
Verfügbar <div className="flex flex-col gap-3 md:space-y-3">
<span className="inline-block w-3 h-3 bg-red-400 rounded-full"></span> {sorted.map((item: any) => (
Außerhalb des Schließfachs <div
key={item.item_name}
className={`group relative w-full bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200/70 duration-200 hover:shadow-md focus-within:ring-slate-300 ${
item.inSafe
? "border-l-4 border-emerald-400"
: "border-l-4 border-red-400 ring-red-200/60 bg-red-50/40"
}`}
>
<div className="flex items-start gap-3">
<span
className="relative mt-0.5 inline-flex"
aria-hidden="true"
>
{!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)}
<span
className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
item.inSafe ? "bg-emerald-500" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe
? "Aktuell im Schließfach"
: "Aktuell nicht im Schließfach"
}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-200/70 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white shadow-sm"></span>
Im Schließfach
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-red-500 rounded-full ring-2 ring-white shadow-sm"></span>
Außerhalb des Schließfachs
</span>
</div>
</div> </div>
</aside> </aside>
); );

View File

@@ -1 +1,12 @@
@import "tailwindcss"; /* Tailwind (v4) */
@import "tailwindcss";
/* Small touch target improvements */
@layer base {
html:focus-within {
scroll-behavior: smooth;
}
:root {
color-scheme: light;
}
}

View File

@@ -2,6 +2,7 @@ import React from "react";
import "../App.css"; import "../App.css";
import Header from "../components/Header"; import Header from "../components/Header";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import Footer from "../components/Footer";
type LayoutProps = { type LayoutProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -10,28 +11,24 @@ type LayoutProps = {
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => { const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return ( return (
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100"> <div className="h-screen flex flex-col bg-slate-50 text-slate-800">
{/* Sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{/* Main */} {/* Main */}
<main className="flex-1 flex flex-col items-center py-10 px-4 md:py-14"> <main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8 pb-12">
<div className="w-full max-w-3xl"> <div className="w-full max-w-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
<Header onLogout={onLogout} /> <div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
</div> <Sidebar />
<div className="w-full max-w-3xl bg-white/90 shadow-2xl rounded-3xl p-6 md:p-10 ring-1 ring-blue-100"> </div>
{children} <div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
<div className="w-full">
<Header onLogout={onLogout} />
</div>
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200 flex-1 min-h-0 overflow-y-auto">
{children}
</div>
</div>
</div> </div>
</main> </main>
<Footer />
{/* Mobile sidebar at bottom */}
<div className="fixed bottom-4 left-4 right-4 md:hidden z-10">
<div className="bg-white/95 backdrop-blur rounded-2xl shadow-xl ring-1 ring-blue-100 p-4">
<Sidebar />
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -2,24 +2,28 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { ToastContainer } from "react-toastify"; import { ToastContainer, Flip } from "react-toastify";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./utils/queryClient";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<ToastContainer <App />
position="top-right" <ToastContainer
autoClose={3000} position="top-right"
hideProgressBar={false} autoClose={3000}
newestOnTop hideProgressBar={false}
closeOnClick newestOnTop
rtl={false} closeOnClick
pauseOnFocusLoss={false} rtl={false}
draggable pauseOnFocusLoss
pauseOnHover draggable
theme="light" pauseOnHover
style={{ zIndex: 9999 }} theme="colored"
/> transition={Flip}
/>
</QueryClientProvider>
</StrictMode> </StrictMode>
); );

View File

@@ -4,18 +4,44 @@ import { myToast } from "./toastify";
// Event name used to notify the app when the list of items has been updated // Event name used to notify the app when the list of items has been updated
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated"; export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated"; export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
export const AUTH_LOGOUT_EVENT = "authLogout";
let sendError = false;
function logout() {
Cookies.remove("token");
Cookies.remove("startDate");
Cookies.remove("endDate");
localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT));
window.dispatchEvent(new Event(AUTH_LOGOUT_EVENT));
}
export const fetchAllData = async (token: string | undefined) => { export const fetchAllData = async (token: string | undefined) => {
if (!token) return; if (!token) return;
// First we fetch all items that are potentially available for borrowing // First we fetch all items that are potentially available for borrowing
try { try {
const response = await fetch("http://localhost:8002/api/items", { const response = await fetch("https://backend.insta.the1s.de/api/items", {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) { if (!response.ok) {
myToast("Failed to fetch items", "error"); myToast("Failed to fetch items", "error");
return; return;
@@ -31,13 +57,23 @@ export const fetchAllData = async (token: string | undefined) => {
// get all loans // get all loans
try { try {
const response = await fetch("http://localhost:8002/api/loans", { const response = await fetch("https://backend.insta.the1s.de/api/loans", {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) { if (!response.ok) {
myToast("Failed to fetch loans!", "error"); myToast("Failed to fetch loans!", "error");
return; return;
@@ -53,13 +89,23 @@ export const fetchAllData = async (token: string | undefined) => {
// get user loans // get user loans
try { try {
const response = await fetch("http://localhost:8002/api/userLoans", { const response = await fetch("https://backend.insta.the1s.de/api/userLoans", {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) { if (!response.ok) {
myToast("Failed to fetch user loans!", "error"); myToast("Failed to fetch user loans!", "error");
return; return;
@@ -76,7 +122,7 @@ export const fetchAllData = async (token: string | undefined) => {
export const loginUser = async (username: string, password: string) => { export const loginUser = async (username: string, password: string) => {
try { try {
const response = await fetch("http://localhost:8002/api/login", { const response = await fetch("https://backend.insta.the1s.de/api/login", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -112,7 +158,7 @@ export const getBorrowableItems = async () => {
} }
try { try {
const response = await fetch("http://localhost:8002/api/borrowableItems", { const response = await fetch("https://backend.insta.the1s.de/api/borrowableItems", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -122,6 +168,16 @@ export const getBorrowableItems = async () => {
body: JSON.stringify({ startDate, endDate }), body: JSON.stringify({ startDate, endDate }),
}); });
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) { if (!response.ok) {
myToast("Failed to fetch borrowable items", "error"); myToast("Failed to fetch borrowable items", "error");
return; return;

View 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,
},
},
});

View File

@@ -1,17 +1,18 @@
import { toast, type ToastOptions } from "react-toastify"; import { toast, Flip, type ToastOptions } from "react-toastify";
export type ToastType = "success" | "error" | "info" | "warning"; export type ToastType = "success" | "error" | "info" | "warning";
export const myToast = (message: string, msgType: ToastType) => { export const myToast = (message: string, msgType: ToastType) => {
let config: ToastOptions = { let config: ToastOptions = {
position: "top-right", position: "top-right",
autoClose: 5000, autoClose: 3000,
hideProgressBar: false, hideProgressBar: false,
closeOnClick: true, closeOnClick: true,
pauseOnHover: true, pauseOnHover: true,
draggable: true, draggable: true,
progress: undefined, progress: undefined,
theme: "light", theme: "colored",
transition: Flip,
}; };
toast[msgType](message, config); toast[msgType](message, config);
}; };

View File

@@ -1,10 +1,11 @@
import { myToast } from "./toastify"; import { myToast } from "./toastify";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { queryClient } from "./queryClient";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => { export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanID}`, `https://backend.insta.the1s.de/api/deleteLoan/${loanID}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -74,7 +75,7 @@ export const rmFromRemove = (itemID: number) => {
export const createLoan = async (startDate: string, endDate: string) => { export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr; const items = removeArr;
const response = await fetch("http://localhost:8002/api/createLoan", { const response = await fetch("https://backend.insta.the1s.de/api/createLoan", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -92,5 +93,10 @@ export const createLoan = async (startDate: string, endDate: string) => {
removeArr = []; removeArr = [];
Cookies.set("removeArr", "[]"); Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success"); myToast("Ausleihe erfolgreich erstellt!", "success");
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
return true; return true;
}; };

View File

@@ -1,5 +1,6 @@
module.exports = { module.exports = {
content: [ content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}",
// add other paths if needed // add other paths if needed
], ],

View File

@@ -1,15 +1,17 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [react(), svgr(), tailwindcss()], plugins: [tailwindcss()],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 8001, allowedHosts: ["insta.the1s.de"],
watch: { port: 8101,
usePolling: true, watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
}, },
}, },
}); });