32 Commits

Author SHA1 Message Date
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
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
c779a31bfa updated toastify design 2025-08-20 13:38:54 +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
8291968e7a maked ui simpler 2025-08-20 01:07:00 +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
32 changed files with 966 additions and 631 deletions

2
.gitignore vendored
View File

@@ -112,3 +112,5 @@ backend/public/uploads/
config/
secrets/
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)
- Auth/JWT: [`authenticate`](backend/services/tokenService.js), [`generateToken`](backend/services/tokenService.js)
- App API (JWT): [backend/routes/api.js](backend/routes/api.js)
- Admin API (key): [backend/routes/apiV2.js](backend/routes/apiV2.js)
- DB layer: [backend/services/database.js](backend/services/database.js)
- Schema: [backend/scheme.sql](backend/scheme.sql)
On this page you will learn how my API works.
## General information
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` is for my web frontend, because this file works together with my JWT token service.
**\*But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.**
This is the file that you can use to build an API.
But first you have to get the Admin API key, stored in an `.env` file on my server.
---
## 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
- 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).
Example: `/apiV2/items/{ADMIN_ID}`
---
## 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
[
{ "id": 1, "item_name": "Laptop", "can_borrow_role": 1, "inSafe": 1 },
...
]
**GET** `/apiV2/items/:key`
Returns a list of all items and their details.
#### Example Request
```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
```
Example:
#### Example Response
```sh
curl -s http://localhost:8002/api/items \
-H "Authorization: Bearer $TOKEN"
```
### GET /api/loans
Returns all loans.
Implements: [`getLoansFromDatabase`](backend/services/database.js)
Response 200:
```json
[
{
"data": [
{
"id": 1,
"username": "alice",
"loan_code": 1001,
"start_date": "2025-08-01T09:00:00.000Z",
"end_date": "2025-08-10T09:00:00.000Z",
"returned_date": null,
"created_at": "2025-08-01T09:00:00.000Z",
"loaned_items_id": [1, 2],
"loaned_items_name": ["Laptop", "Projector"]
"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/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:
Each item has the following properties:
- IDs are coerced to numbers; invalid entries are dropped.
- Date range must be valid and `startDate < endDate`.
- Overlaps with existing loans cause 409 Conflict.
- On success, returns the generated `loanCode`.
- `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.
Responses:
_You also get an http 200 status code._
- 201:
---
```json
### 2. Change Item Safe State
**POST** `/apiV2/controlInSafe/:key/:itemId/:state`
Updates the `inSafe` state of an item (whether it is in the locker).
- `state` must be `"1"` (in safe) or `"0"` (not in safe).
#### 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
```
{
"message": "Loan created successfully",
"loanId": 123,
"loanCode": 1007
"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,
"created_at": "2025-08-20T11:23:40.000Z",
"loaned_items_id": [
8,
9
],
"loaned_items_name": [
"SD Karten",
"Kameragimbal"
]
}
}
```
- 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" }`
_You also get an http 200 status code._
Example:
If the loan id does not exist, you will receive a 404 status code and an error message.
```sh
curl -s -X POST http://localhost:8002/api/createLoan \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"items":[1,2],"startDate":"2025-08-01T09:00:00Z","endDate":"2025-08-02T09:00:00Z"}'
```
{
"message": "Loan not found"
}
```
---
## Admin API — /apiV2
## Error Handling
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
```
- `403 Forbidden`: Invalid or missing API key.
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
- `500 Internal Server Error`: Database or server error.
---
## 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),
If you have questions or want to collaborate, please reach out to me!

Binary file not shown.

View File

@@ -0,0 +1,73 @@
# Borrow System
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?logo=tailwind-css&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
![JWT](https://img.shields.io/badge/JWT-000000?logo=jsonwebtokens&logoColor=white)
A small fullstack system to log in, view available items, reserve them for a time window, and manage personal loans.
- Frontend: React + TypeScript + Vite + Tailwind CSS
- Backend: Node.js + Express + MySQL + JWT (jose)
- Orchestration: Docker Compose (backend + MySQL)
## Contents
- Frontend: [frontend/](frontend)
- Vite/Tailwind config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
- UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
- Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
- Backend: [backend/](backend)
- Server: [backend/server.js](backend/server.js)
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
- DB + services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
- Docs: [docs/](docs)
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
## Features (highlevel)
- Auth via JWT (login -> token cookie) using the backend route in [backend/routes/api.js](backend/routes/api.js).
- After login, the app loads items, loans, and user loans and keeps them in localStorage.
- Choose a date range to fetch borrowable items, select items, and create a loan.
- Manage personal loans list (and delete a loan).
Key frontend utilities:
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected time range.
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
UI flow (main screens):
- Period selection: [frontend/src/components/Form1.tsx](frontend/src/components/Form1.tsx)
- Borrowable items + selection: [frontend/src/components/Form2.tsx](frontend/src/components/Form2.tsx)
- User loans table: [frontend/src/components/Form4.tsx](frontend/src/components/Form4.tsx)
## Development
- Scripts: see [frontend/package.json](frontend/package.json) and [backend/package.json](backend/package.json)
- Frontend: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint`
- Backend: `npm start`
- Linting: ESLint configured via [frontend/eslint.config.js](frontend/eslint.config.js)
- TypeScript configs: [frontend/tsconfig.app.json](frontend/tsconfig.app.json), [frontend/tsconfig.node.json](frontend/tsconfig.node.json)
## Configuration notes
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Crosscomponent updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
## API documentation
Refer to the dedicated API docs:
`docs/backend_API_docs/README.md`

View File

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

View File

@@ -3,16 +3,20 @@ import dotenv from "dotenv";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
getLoanByCodeV2,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
// Route for API to get ALL items from the database
router.get("/items/:key", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json(result.data);
res.status(200).json({ data: result.data });
} else {
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) => {
if (req.params.key === process.env.ADMIN_ID) {
const itemId = req.params.itemId;
@@ -28,7 +33,7 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state);
if (result.success) {
res.status(200).json({ message: "Item state updated successfully" });
res.status(200).json({ data: result.data });
} else {
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;

View File

@@ -7,6 +7,7 @@ CREATE TABLE `users` (
`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`)
);
@@ -17,6 +18,7 @@ CREATE TABLE `loans` (
`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 ('[]'),
@@ -30,6 +32,7 @@ CREATE TABLE `items` (
`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`)
);
@@ -38,100 +41,40 @@ 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`)
);
-- 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
('Laptop', 1, 1),
('Projector', 2, 1),
('Tablet', 1, 1),
('Camera', 2, 1),
('Tripod', 1, 1),
('Microphone', 3, 1),
('Speaker', 2, 1),
('Monitor', 1, 1),
('Keyboard', 2, 1),
('Mouse', 1, 1),
('Printer', 3, 1),
('Scanner', 2, 1),
('Router', 1, 1),
('Switch', 2, 1),
('Charger', 1, 1),
('USB Cable', 2, 1),
('HDMI Cable', 1, 1),
('Webcam', 3, 1),
('Headphones', 2, 1),
('Smartphone', 1, 1);
('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);
-- Mock data for lockers
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('Laptop', 101),
('Projector', 102),
('Tablet', 103),
('Camera', 104),
('Tripod', 105),
('Microphone', 106),
('Speaker', 107),
('Monitor', 108),
('Keyboard', 109),
('Mouse', 110),
('Printer', 111),
('Scanner', 112),
('Router', 113),
('Switch', 114),
('Charger', 115),
('USB Cable', 116),
('HDMI Cable', 117),
('Webcam', 118),
('Headphones', 119),
('Smartphone', 120);
('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);

View File

@@ -28,6 +28,17 @@ export const getItemsFromDatabaseV2 = async () => {
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) => {
const [result] = await pool.query(
"UPDATE items SET inSafe = ? WHERE id = ?",
@@ -39,6 +50,28 @@ export const changeInSafeStateV2 = async (itemId, state) => {
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) => {
const sql =
role == 0

View File

@@ -1,10 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<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" />
<title>Vite + React + TS</title>
<title>Ausleihsystem</title>
</head>
<body>
<div id="root"></div>

View File

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

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0",
"@tanstack/react-query": "^5.85.5",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"primeicons": "^7.0.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 LoginForm from "./components/LoginForm";
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";
function App() {
@@ -18,18 +22,23 @@ function App() {
setIsLoggedIn(true);
fetchAllData(token);
}
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 = () => {
Cookies.remove("token");
localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
// Let listeners refresh from empty state
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success");
setIsLoggedIn(false);
@@ -37,11 +46,11 @@ function App() {
return isLoggedIn ? (
<Layout onLogout={handleLogout}>
<div className="space-y-10">
<div className="space-y-6">
<Form1 />
<div className="h-px bg-blue-100" />
<div className="h-px bg-slate-200" />
<Form2 />
<div className="h-px bg-blue-100" />
<div className="h-px bg-slate-200" />
<Form4 />
</div>
</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 = () => {
return (
<div className="space-y-6">
<h2 className="text-xl font-bold text-blue-700">1. Zeitraum wählen</h2>
<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-4"
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
@@ -17,13 +19,13 @@ const Form1: React.FC = () => {
Cookies.set("startDate", start);
Cookies.set("endDate", end);
getBorrowableItems();
console.log("Zeitraum erfolgreich gesetzt!");
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
htmlFor="startDate"
className="block text-sm font-medium text-blue-800 mb-1"
className="block text-sm font-medium text-slate-700 mb-1"
>
Start
</label>
@@ -31,13 +33,13 @@ const Form1: React.FC = () => {
type="datetime-local"
id="startDate"
name="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"
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-blue-800 mb-1"
className="block text-sm font-medium text-slate-700 mb-1"
>
Ende
</label>
@@ -45,12 +47,13 @@ const Form1: React.FC = () => {
type="datetime-local"
id="endDate"
name="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"
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-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
</button>

View File

@@ -1,7 +1,6 @@
import React from "react";
import Cookies from "js-cookie";
import { createLoan } from "../utils/userHandler";
import { addToRemove, rmFromRemove } from "../utils/userHandler";
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem {
@@ -13,14 +12,6 @@ interface BorrowItem {
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[] {
const rawArr = Array.isArray(data)
? data
@@ -52,7 +43,6 @@ function normalizeBorrowable(data: any): BorrowItem[] {
.filter(Boolean) as BorrowItem[];
}
// Hook, der automatisch aus dem Local Storage liest und auf Änderungen hört
function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]);
@@ -68,16 +58,13 @@ function useBorrowableItems() {
}, []);
React.useEffect(() => {
// Initial read
readFromStorage();
// Cross-tab updates
const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
};
window.addEventListener("storage", onStorage);
// Same-tab updates via Custom Event
const onBorrowableUpdated = () => readFromStorage();
window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
@@ -100,48 +87,74 @@ const Form2: React.FC = () => {
const items = useBorrowableItems();
return (
<div className="space-y-6">
<h2 className="text-xl font-bold text-blue-700">
<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-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.
</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">
<thead className="bg-blue-50/60">
<>
{/* 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-blue-700">
<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-blue-700">
<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-blue-50">
<tbody className="divide-y divide-slate-100">
{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">
<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-blue-700">
<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);
}
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>
@@ -149,9 +162,10 @@ const Form2: React.FC = () => {
</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
onClick={() => {
createLoan(
@@ -160,7 +174,7 @@ const Form2: React.FC = () => {
);
}}
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
</button>

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react";
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";
type Loan = {
id: number;
@@ -8,6 +11,7 @@ type Loan = {
loan_code: number;
start_date: string;
end_date: string;
take_date: string | null;
returned_date: string | null;
created_at: string;
loaned_items_id: number[];
@@ -21,45 +25,43 @@ const formatDate = (iso: string | null) => {
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
};
const readUserLoansFromStorage = (): Loan[] => {
const raw = localStorage.getItem("userLoans");
if (!raw || raw === '"No loans found for this user"') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Loan[]) : [];
} catch {
return [];
}
};
async function fetchUserLoans(): Promise<Loan[]> {
const res = await fetch("http://localhost:8002/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 [userLoans, setUserLoans] = useState<Loan[]>(() =>
readUserLoansFromStorage()
);
const { data: userLoans = [], isFetching } = useQuery({
queryKey: ["userLoans"],
queryFn: fetchUserLoans,
});
// Keep in sync if localStorage changes (e.g., other tabs or parts of the app)
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "userLoans") {
setUserLoans(readUserLoansFromStorage());
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const deleteMutation = useMutation({
mutationFn: (loanID: number) => handleDeleteLoan(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const onDelete = async (loanID: number) => {
const ok = await handleDeleteLoan(loanID);
if (ok) {
setUserLoans((prev) =>
prev.filter((l) => Number(l.id) !== Number(loanID))
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-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>
</div>
);
@@ -67,65 +69,117 @@ const Form4: React.FC = () => {
return (
<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
</p>
<p className="text-sm text-gray-600">
Wenn du eine Ausleihe ändern oder löschen möchtest, klicke auf das
Papierkorb-Symbol.
<p className="text-sm text-slate-600">
Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
</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">
<table className="min-w-full text-sm text-gray-700">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr className="border-b border-gray-200">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
<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-gray-600">
<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-gray-600">
<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-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
</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
</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
</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
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-slate-100">
{userLoans.map((loan) => (
<tr
key={loan.id}
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">
<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-gray-900">
<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-gray-900">
<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-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)}
</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)}
</td>
<td className="px-4 py-3">
<div className="max-w-[22rem] truncate text-gray-900">
<td className="px-4 py-3 whitespace-nowrap">
<div className="text-slate-900">
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
@@ -135,7 +189,7 @@ const Form4: React.FC = () => {
<button
onClick={() => onDelete(loan.id)}
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" />
</button>
@@ -145,7 +199,6 @@ const Form4: React.FC = () => {
</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">

View File

@@ -6,20 +6,34 @@ type HeaderProps = {
const Header: React.FC<HeaderProps> = ({ onLogout }) => {
return (
<header className="mb-6 md:mb-10">
<h1 className="text-3xl md:text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
<header className="mb-4 sm:mb-6">
<div className="flex items-start justify-between gap-3">
<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-blue-500 mt-1 md:mt-2 text-base md:text-lg font-medium">
<p className="text-slate-600 mt-1 text-sm sm:text-base">
Schnell und unkompliziert Equipment reservieren
</p>
</div>
<button
type="button"
onClick={onLogout}
className="text-blue-500 hover:underline"
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>
);
};

View File

@@ -1,4 +1,5 @@
import React from "react";
import Footer from "./Footer";
import { useState } from "react";
import { loginUser } from "../utils/fetchData";
import { myToast } from "../utils/toastify";
@@ -22,16 +23,16 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
};
return (
<div className="bg-blue-950 min-h-screen">
<div className="max-w-sm mx-auto mt-20 bg-white rounded-xl shadow-lg p-8 border border-blue-100">
<h2 className="text-2xl font-bold text-blue-700 mb-6 text-center">
<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-5">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
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
</label>
@@ -39,14 +40,14 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
type="text"
onChange={(e) => setUsername(e.target.value)}
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
/>
</div>
<div>
<label
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
</label>
@@ -54,18 +55,19 @@ const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
onChange={(e) => setPassword(e.target.value)}
type="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
/>
</div>
<button
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
</button>
</form>
</div>
<Footer />
</div>
);
};

View File

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

View File

@@ -1,5 +1,6 @@
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 = () => {
@@ -12,57 +13,51 @@ const Sidebar: React.FC = () => {
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
setItems(next);
};
// Update immediately in case data changed before this mounted
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)); // außerhalb zuerst
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
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">
<h2 className="text-2xl font-extrabold mb-4 text-blue-700 tracking-tight flex items-center justify-between">
<span className="flex items-center gap-2">
<svg
className="w-6 h-6 text-blue-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
/>
</svg>
Geräte Übersicht
<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="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
</span>
)}
</h2>
</div>
<div className="space-y-4 flex-1 pr-1">
{/* 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={`bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition ${
item.inSafe ? "" : "ring-1 ring-red-200"
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-1 inline-flex" aria-hidden="true">
<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 ${
item.inSafe ? "bg-green-400" : "bg-red-500"
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"
@@ -75,7 +70,9 @@ const Sidebar: React.FC = () => {
<Object
title={item.item_name}
description={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
item.inSafe
? "Aktuell im Schließfach"
: "Aktuell nicht im Schließfach"
}
/>
</div>
@@ -83,11 +80,16 @@ const Sidebar: React.FC = () => {
))}
</div>
<div className="mt-6 text-xs text-gray-400 flex items-center gap-4">
<span className="inline-block w-3 h-3 bg-green-400 rounded-full"></span>
Verfügbar
<span className="inline-block w-3 h-3 bg-red-400 rounded-full"></span>
<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>
);

View File

@@ -1 +1,12 @@
/* 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 Header from "../components/Header";
import Sidebar from "../components/Sidebar";
import Footer from "../components/Footer";
type LayoutProps = {
children: React.ReactNode;
@@ -10,28 +11,24 @@ type LayoutProps = {
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return (
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
{/* Sidebar */}
<div className="hidden md:block">
<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>
{/* Main */}
<main className="flex-1 flex flex-col items-center py-10 px-4 md:py-14">
<div className="w-full max-w-3xl">
<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 max-w-3xl bg-white/90 shadow-2xl rounded-3xl p-6 md:p-10 ring-1 ring-blue-100">
<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>
{/* 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>
<Footer />
</div>
);
};

View File

@@ -2,11 +2,14 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ToastContainer } from "react-toastify";
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"
@@ -15,11 +18,12 @@ createRoot(document.getElementById("root")!).render(
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
style={{ zIndex: 9999 }}
theme="colored"
transition={Flip}
/>
</QueryClientProvider>
</StrictMode>
);

View File

@@ -4,6 +4,22 @@ import { myToast } from "./toastify";
// 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";
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;
@@ -16,6 +32,16 @@ export const fetchAllData = async (token: string | undefined) => {
},
});
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;
@@ -38,6 +64,16 @@ export const fetchAllData = async (token: string | undefined) => {
},
});
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;
@@ -60,6 +96,16 @@ export const fetchAllData = async (token: string | undefined) => {
},
});
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;
@@ -122,6 +168,16 @@ export const getBorrowableItems = async () => {
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;

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 const myToast = (message: string, msgType: ToastType) => {
let config: ToastOptions = {
position: "top-right",
autoClose: 5000,
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
theme: "colored",
transition: Flip,
};
toast[msgType](message, config);
};

View File

@@ -1,5 +1,6 @@
import { myToast } from "./toastify";
import Cookies from "js-cookie";
import { queryClient } from "./queryClient";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try {
@@ -92,5 +93,10 @@ export const createLoan = async (startDate: string, endDate: string) => {
removeArr = [];
Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success");
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
return true;
};

View File

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