54 Commits

Author SHA1 Message Date
1b15cf96e8 changed url 2025-08-28 21:38:17 +02:00
bb7f66ecc7 changed urls 2025-08-28 21:15:37 +02:00
59de6d69d9 Merge branch 'dev_v1' into debian12_v1 2025-08-28 21:14:48 +02:00
478f03452d added take and return setter buttons 2025-08-28 21:12:58 +02:00
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
b065f234bc added documentation for the Backend API 2025-08-19 19:33:38 +02:00
508c30c5d0 Added backend external API to change the inSafe state in the database 2025-08-19 19:33:16 +02:00
9287c949ca feat: Implement loan management features including fetching, creating, and deleting loans
- Added database functions for managing loans: getLoans, getUserLoans, deleteLoan, and createLoan.
- Updated frontend to include new Form4 for displaying user loans and handling loan deletions.
- Replaced Form3 with Form4 in App component.
- Enhanced Form1 to set borrowing dates and fetch available items.
- Improved Form2 to display borrowable items and allow selection for loans.
- Introduced utility functions for handling loan creation and deletion in userHandler.
- Added event listeners for local storage updates to keep UI in sync.
- Updated fetchData utility to retrieve loans and user loans from the backend.
2025-08-19 19:13:52 +02:00
38 changed files with 1867 additions and 372 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,121 +1,306 @@
# Backend API docs
If you want to coorperate with me, or build something new with my backend API, feel free to reach out!
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
On this page you will learn how my API works.
## General information
When you look at my backend folder and file structure, yu 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.
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 build a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
**\*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.
But first you have to get the Admin API key, stored in an `.env` file on my server.
### Current endpoints
---
- /apiV2/items/`secretKey`
## Authentication
#### /apiV2/items/
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
When you call this API you will get like this result back:
Example: `/apiV2/items/{ADMIN_ID}`
---
## URL
- The frontend is currently running on `https://insta.the1s.de`.
- The backend is currently running on `https://backend.insta.the1s.de`.
You can see the status of this and all my other services at `https://status.the1s.de`.
---
## Current endpoints
### 1. Get All Items
**GET** `/apiV2/items/:key`
Returns a list of all items and their details.
#### Example Request
```
[
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": "4",
"inSafe": 1
},
{
"id": 2,
"item_name": "DJI 2er Mikro 1",
"can_borrow_role": "4",
"inSafe": 1
},
{
"id": 3,
"item_name": "DJI 2er Mikro 2",
"can_borrow_role": "4",
"inSafe": 1
},
{
"id": 5,
"item_name": "Rode Richt Mikrofon",
"can_borrow_role": "2",
"inSafe": 1
},
{
"id": 6,
"item_name": "Kamera Stativ",
"can_borrow_role": "1",
"inSafe": 1
},
{
"id": 7,
"item_name": "SONY Kamera - inkl. Akkus und Objektiv",
"can_borrow_role": "1",
"inSafe": 1
},
{
"id": 8,
"item_name": "MacBook inkl. Adapter",
"can_borrow_role": "2",
"inSafe": 1
},
{
"id": 9,
"item_name": "SD Karten",
"can_borrow_role": "3",
"inSafe": 1
},
{
"id": 10,
"item_name": "Kameragimbal",
"can_borrow_role": "1",
"inSafe": 1
},
{
"id": 11,
"item_name": "ATEM MINI PRO",
"can_borrow_role": "1",
"inSafe": 1
},
{
"id": 12,
"item_name": "Handygimbal",
"can_borrow_role": "4",
"inSafe": 1
},
{
"id": 13,
"item_name": "Kameralüfter",
"can_borrow_role": "1",
"inSafe": 1
},
{
"id": 14,
"item_name": "Kleine Kamera 1 - inkl. Objektiv",
"can_borrow_role": "2",
"inSafe": 1
},
{
"id": 15,
"item_name": "Kleine Kamera 2 - inkl. Objektiv",
"can_borrow_role": "2",
"inSafe": 1
}
]
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
```
There you can see all items and their details.
#### Example Response
```
{
"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"
}
]
}
```
Each item has the following properties:
- `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).
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time.
_You also get an http 200 status code._
---
### 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
```
{
"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"
]
}
}
```
_You also get an http 200 status code._
If the loan id does not exist, you will receive a 404 status code and an error message.
```
{
"message": "Loan not found"
}
```
---
## Error Handling
- `403 Forbidden`: Invalid or missing API key.
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
- `500 Internal Server Error`: Database or server error.
---
If you have questions or want to collaborate, please reach out to me!

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 . .
EXPOSE 8002
EXPOSE 8102
CMD ["npm", "start"]

View File

@@ -1,9 +1,18 @@
import express from "express";
import { loginFunc, getItemsFromDatabase } from "../services/database.js";
import {
loginFunc,
getItemsFromDatabase,
getLoansFromDatabase,
getUserLoansFromDatabase,
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
onTake,
onReturn,
} from "../services/database.js";
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) {
@@ -27,4 +36,134 @@ router.get("/items", authenticate, async (req, res) => {
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.get("/userLoans", authenticate, async (req, res) => {
const result = await getUserLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch user loans" });
}
});
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.post("/borrowableItems", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
router.post("/takeLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onTake(loanId);
if (result.success) {
res.status(200).json({ message: "Loan taken successfully" });
} else {
res.status(500).json({ message: "Failed to take loan" });
}
});
router.post("/returnLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onReturn(loanId);
if (result.success) {
res.status(200).json({ message: "Loan returned successfully" });
} else {
res.status(500).json({ message: "Failed to return loan" });
}
});
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
itemIds
);
if (result.success) {
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
}
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
}
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
}
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
});
export default router;

View File

@@ -1,15 +1,22 @@
import express from "express";
import dotenv from "dotenv";
import { getItemsFromDatabaseV2 } from "../services/database.js";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
getLoanByCodeV2,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
router.get("/items/:id", async (req, res) => {
if (req.params.id === process.env.ADMIN_ID) {
// 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" });
}
@@ -18,4 +25,69 @@ router.get("/items/:id", 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;
const state = req.params.state;
if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to update item state" });
}
} else {
res.status(400).json({ message: "Invalid state value" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(404).json({ message: "Loan not found" });
}
}
});
// Route for API to set the return date
router.post("/setReturnDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
// Route for API to set the take away date
router.post("/setTakeDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
export default router;

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,9 +18,11 @@ 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 ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`)
);
@@ -27,8 +30,9 @@ CREATE TABLE `loans` (
CREATE TABLE `items` (
`id` int NOT NULL AUTO_INCREMENT,
`item_name` varchar(255) NOT NULL,
`can_borrow_role` 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`)
);
@@ -37,33 +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
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1),
('DJI 2er Mikro 2', 4, 1),
('Rode Richt Mikrofon', 2, 1),
('Kamera Stativ', 1, 0),
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
('MacBook inkl. Adapter', 2, 0),
('SD Karten', 3, 0),
('Kameragimbal', 1, 0),
('ATEM MINI PRO', 1, 1),
('Handygimbal', 4, 0),
('Kameralüfter', 1, 1),
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
-- Users
INSERT INTO users (username, password) VALUES
('alice', 'password123'),
('bob', 'securepass'),
('charlie', 'charliepwd');
-- Items
INSERT INTO items (item_name, can_borrow_role) VALUES
('Laptop', 'student'),
('Projector', 'teacher'),
('Tablet', 'student,teacher');
-- Loans
INSERT INTO loans (username, loan_code, start_date, end_date, returned_date) VALUES
('alice', 100001, '2025-08-01 09:00:00', '2025-08-10 17:00:00', NULL),
('bob', 100002, '2025-08-05 10:00:00', '2025-08-12 15:00:00', '2025-08-12 14:30:00'),
('charlie', 100003, '2025-08-07 11:00:00', '2025-08-15 16:00:00', NULL);
-- Lockers
INSERT INTO lockers (item, locker_number) VALUES
('Laptop', 101),
('Projector', 102),
('Tablet', 103);
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('DJI 1er Mikro', 1),
('DJI 2er Mikro 1', 2),
('DJI 2er Mikro 2', 3),
('Rode Richt Mikrofon', 4),
('Kamera Stativ', 5),
('SONY Kamera - inkl. Akkus und Objektiv', 6),
('MacBook inkl. Adapter', 7),
('SD Karten', 8),
('Kameragimbal', 9),
('ATEM MINI PRO', 10),
('Handygimbal', 11),
('Kameralüfter', 12),
('Kleine Kamera 1 - inkl. Objektiv', 13),
('Kleine Kamera 2 - inkl. Objektiv', 14);

View File

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

View File

@@ -8,6 +8,7 @@ const pool = mysql
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
})
.promise();
@@ -28,6 +29,50 @@ 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 = ?",
[state, itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
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
@@ -41,3 +86,237 @@ export const getItemsFromDatabase = async (role) => {
}
return { success: false };
};
export const getLoansFromDatabase = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
username,
]);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
return { success: true, data: "No loans found for this user" };
} else {
return { success: false };
}
};
export const deleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
loanId,
]);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createLoanInDatabase = async (
username,
startDate,
endDate,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names
const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(1000 + Math.random() * 900000); // 4-6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
`,
[
username,
loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const onReturn = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};

View File

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

View File

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

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

@@ -3,10 +3,14 @@ import Layout from "./layout/Layout";
import { useEffect, useState } from "react";
import Form1 from "./components/Form1";
import Form2 from "./components/Form2";
import Form3 from "./components/Form3";
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,16 +22,23 @@ function App() {
setIsLoggedIn(true);
fetchAllData(token);
}
localStorage.setItem("borrowableItems", JSON.stringify([]));
localStorage.setItem("borrowCode", "123456");
}, []);
// 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");
// Let listeners refresh from empty state
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success");
setIsLoggedIn(false);
@@ -35,12 +46,12 @@ 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" />
<Form3 />
<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

@@ -1,39 +1,59 @@
import React from "react";
import Cookies from "js-cookie";
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>
<form className="space-y-4">
<div>
<label
htmlFor="startDate"
className="block text-sm font-medium text-blue-800 mb-1"
>
Start
</label>
<input
type="datetime-local"
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"
/>
</div>
<div>
<label
htmlFor="endDate"
className="block text-sm font-medium text-blue-800 mb-1"
>
Ende
</label>
<input
type="datetime-local"
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"
/>
<div className="space-y-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
1. Zeitraum wählen
</h2>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const fd = new FormData(form);
const start = (fd.get("startDate") as string) || "";
const end = (fd.get("endDate") as string) || "";
Cookies.set("startDate", start);
Cookies.set("endDate", end);
getBorrowableItems();
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
htmlFor="startDate"
className="block text-sm font-medium text-slate-700 mb-1"
>
Start
</label>
<input
type="datetime-local"
id="startDate"
name="startDate"
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
/>
</div>
<div>
<label
htmlFor="endDate"
className="block text-sm font-medium text-slate-700 mb-1"
>
Ende
</label>
<input
type="datetime-local"
id="endDate"
name="endDate"
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
/>
</div>
</div>
<button
type="submit"
className="w-full bg-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,59 +1,184 @@
import React from "react";
import Cookies from "js-cookie";
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
}
const LOCAL_STORAGE_KEY = "borrowableItems";
function normalizeBorrowable(data: any): BorrowItem[] {
const rawArr = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: Array.isArray(data?.data)
? data.data
: [];
return rawArr
.map((raw: any) => {
const idRaw =
raw.id ?? raw.item_id ?? raw.itemId ?? raw.itemID ?? raw.itemIdPk;
const id = Number(idRaw);
const item_name = String(raw.item_name ?? raw.name ?? raw.title ?? "");
const can_borrow_role = String(
raw.can_borrow_role ?? raw.role ?? raw.requiredRole ?? ""
);
const inSafeRaw =
raw.inSafe ?? raw.in_safe ?? raw.inLocker ?? raw.isInSafe ?? raw.safe;
const inSafe =
typeof inSafeRaw === "boolean"
? Number(inSafeRaw)
: Number(isNaN(Number(inSafeRaw)) ? 0 : Number(inSafeRaw));
if (!Number.isFinite(id) || !item_name) return null;
return { id, item_name, can_borrow_role, inSafe };
})
.filter(Boolean) as BorrowItem[];
}
function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]);
const readFromStorage = React.useCallback(() => {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]";
const parsed = JSON.parse(raw);
const arr = normalizeBorrowable(parsed);
setItems(arr);
} catch {
setItems([]);
}
}, []);
React.useEffect(() => {
readFromStorage();
const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
};
window.addEventListener("storage", onStorage);
const onBorrowableUpdated = () => readFromStorage();
window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
};
}, [readFromStorage]);
return items;
}
const Form2: React.FC = () => {
const items = JSON.parse(localStorage.getItem("borrowableItems") || "[]");
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>
<form className="space-y-4">
{items.length === 0 ? (
<div className="text-red-600 font-medium text-center bg-red-50 border border-red-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item: any) => (
{items.length === 0 ? (
<div className="text-slate-700 text-center bg-slate-100 border border-slate-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<>
{/* Mobile: card list */}
<div className="sm:hidden space-y-2">
{items.map((item) => (
<label
key={item.id}
htmlFor={String(item.id)}
className="group cursor-pointer bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition border border-blue-100 flex items-start gap-3"
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"
name={String(item.id)}
id={String(item.id)}
className="mt-1 h-4 w-4 rounded border-blue-300 text-blue-600 focus:ring-blue-400"
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"
/>
<div>
<h3 className="text-sm font-semibold text-blue-800">
{item.title}
</h3>
<p className="text-xs text-blue-500/80 line-clamp-2">
{item.description}
</p>
</div>
</label>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
type="button"
className="flex-1 sm:flex-none sm:w-36 bg-white text-blue-700 border border-blue-200 hover:bg-blue-50 font-medium py-2 px-4 rounded-xl shadow-sm transition"
>
Zurück
</button>
<button
type="submit"
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"
>
Ausleihen
</button>
</div>
</form>
{/* Desktop: table */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-slate-200 shadow-sm bg-white">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
Gegenstand
</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
<input type="checkbox" className="invisible" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-sm font-medium text-slate-900">
{item.item_name}
</td>
<td className="px-4 py-2 text-sm text-slate-700 text-right">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
id={`item-${item.id}`}
className="h-4 w-4 accent-indigo-600"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-1">
<button
onClick={() => {
createLoan(
Cookies.get("startDate") ?? "",
Cookies.get("endDate") ?? ""
);
}}
type="button"
className="w-full sm:w-44 bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
>
Ausleihen
</button>
</div>
</div>
);
};

View File

@@ -1,22 +0,0 @@
import React from "react";
const Form3: React.FC = () => {
const code = localStorage.getItem("borrowCode") ?? "—";
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-blue-700">3. Ausleihe bestätigt</h2>
<div className="mt-2 p-6 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
<p className="text-blue-800 font-semibold">Ihr Ausleihcode lautet</p>
<div className="text-3xl font-extrabold tracking-widest text-blue-700 mt-1">
{code}
</div>
<p className="text-blue-600 mt-2 text-sm">
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
</p>
</div>
</div>
);
};
export default Form3;

View File

@@ -0,0 +1,271 @@
import React from "react";
import { Trash, ArrowLeftRight } from "lucide-react";
import { handleDeleteLoan } from "../utils/userHandler";
import { useMutation, useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { queryClient } from "../utils/queryClient";
import { onTake, onReturn } from "../utils/userHandler";
type Loan = {
id: number;
username: string;
loan_code: number;
start_date: string;
end_date: string;
take_date: string | null;
returned_date: string | null;
created_at: string;
loaned_items_id: number[];
loaned_items_name: string[];
};
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
};
async function fetchUserLoans(): Promise<Loan[]> {
const res = await fetch("https://backend.insta.the1s.de/api/userLoans", {
method: "GET",
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
});
if (!res.ok) throw new Error("Failed to fetch user loans");
const data = await res.json();
if (data === "No loans found for this user") return [];
return Array.isArray(data) ? (data as Loan[]) : [];
}
const Form4: React.FC = () => {
const { data: userLoans = [], isFetching } = useQuery({
queryKey: ["userLoans"],
queryFn: fetchUserLoans,
});
const deleteMutation = useMutation({
mutationFn: (loanID: number) => handleDeleteLoan(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const takeMutation = useMutation({
mutationFn: (loanID: number) => onTake(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const returnMutation = useMutation({
mutationFn: (loanID: number) => onReturn(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const onDelete = (loanID: number) => deleteMutation.mutate(loanID);
if (isFetching) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
<p>Lade Ausleihen</p>
</div>
);
}
if (userLoans.length === 0) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
<p>Keine Ausleihen gefunden.</p>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-lg font-semibold tracking-tight text-slate-900">
Meine Ausleihen
</p>
<p className="text-sm text-slate-600">
Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
</p>
{/* Mobile: cards */}
<div className="space-y-2 sm:hidden">
{userLoans.map((loan) => (
<div
key={loan.id}
className="rounded-xl border border-slate-200 bg-white p-3 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">
Leihcode: <span className="font-mono">{loan.loan_code}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-700">
<div>
<span className="text-slate-500">Start:</span>{" "}
{formatDate(loan.start_date)}
</div>
<div>
<span className="text-slate-500">Ende:</span>{" "}
{formatDate(loan.end_date)}
</div>
<div>
<span className="text-slate-500">Abgeholt:</span>{" "}
{loan.take_date ? (
formatDate(loan.take_date)
) : (
<button
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
onClick={() => takeMutation.mutate(loan.id)}
disabled={takeMutation.isPending}
>
{takeMutation.isPending ? "..." : "Abholen"}
</button>
)}
</div>
<div>
<span className="text-slate-500">Zurück:</span>{" "}
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<button
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
onClick={() => returnMutation.mutate(loan.id)}
disabled={returnMutation.isPending || !loan.take_date}
title={!loan.take_date ? "Erst abholen" : ""}
>
{returnMutation.isPending ? "..." : "Zurückgeben"}
</button>
)}
</div>
</div>
<div className="mt-2 text-xs text-slate-700">
<span className="text-slate-500">Gegenstände:</span>{" "}
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</div>
</div>
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="flex items-center justify-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-5 w-5" />
</button>
</div>
</div>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="table-auto min-w-full text-sm text-slate-700">
<thead className="sticky top-0 z-10 bg-slate-50">
<tr className="border-b border-slate-200">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Leihcode
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Start
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Ende
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Abgeholt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Zurückgegeben
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Erstellt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Gegenstände
</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-600">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{userLoans.map((loan) => (
<tr key={loan.id} className="odd:bg-white even:bg-slate-50">
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.loan_code}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.start_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.end_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.take_date ? (
formatDate(loan.take_date)
) : (
<button
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
onClick={() => takeMutation.mutate(loan.id)}
disabled={takeMutation.isPending}
>
{takeMutation.isPending ? "..." : "Abholen"}
</button>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<button
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
onClick={() => returnMutation.mutate(loan.id)}
disabled={returnMutation.isPending || !loan.take_date}
title={!loan.take_date ? "Erst abholen" : ""}
>
{returnMutation.isPending ? "..." : "Zurückgeben"}
</button>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.created_at)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="text-slate-900">
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: ""}
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="inline-flex items-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Scroll hint */}
<div className="border-t border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ArrowLeftRight className="h-4 w-4 text-gray-400" />
<span>Hinweis: Horizontal scrollen, um alle Spalten zu sehen.</span>
</div>
</div>
</div>
</div>
);
};
export default Form4;

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">
Gegenstand ausleihen
</h1>
<p className="text-blue-500 mt-1 md:mt-2 text-base md:text-lg font-medium">
Schnell und unkompliziert Equipment reservieren
</p>
<button
type="button"
onClick={onLogout}
className="text-blue-500 hover:underline"
>
Logout
</button>
<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-slate-600 mt-1 text-sm sm:text-base">
Schnell und unkompliziert Equipment reservieren
</p>
</div>
<button
type="button"
onClick={onLogout}
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,12 +7,9 @@ type ObjectProps = {
const Object: React.FC<ObjectProps> = ({ title, description }) => {
return (
<div className="flex items-start gap-3">
<div className="shrink-0 w-3 h-3 mt-1.5 rounded-full bg-green-400" />
<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>
<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,52 +13,83 @@ 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);
}, []);
return (
<aside className="w-full md:w-80 md:min-h-screen 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 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
</h2>
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
<div className="space-y-4 flex-1 overflow-auto pr-1">
{items.map((item: any) => (
<div
key={item.item_name}
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition"
>
<Object
title={item.item_name}
description={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</div>
))}
return (
<aside className="w-full md:w-72 md:h-full flex flex-col rounded-2xl pt-0 px-3 pb-3 sm:pt-0 sm:px-4 sm:pb-4 bg-gradient-to-b from-white to-slate-50 ring-1 ring-slate-200/70 shadow-md overflow-hidden">
<div className="sticky top-0 z-10 -mx-3 sm:-mx-4 px-3 sm:px-4 py-2.5 bg-white/85 backdrop-blur supports-[backdrop-filter]:backdrop-blur border-b border-slate-200/70 text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between gap-2 rounded-t-2xl">
<span className="flex items-center gap-2 min-w-0 flex-1 truncate">
<MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
<span className="truncate">Geräte</span>
</span>
{outCount > 0 && (
<span className="inline-flex items-center gap-1 whitespace-nowrap tabular-nums text-[10px] sm:text-xs px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 ring-1 ring-amber-200/70 shadow-sm font-medium">
{outCount} außerhalb
</span>
)}
</div>
<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>
Außerhalb des Schließfachs
{/* Scroll area */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-3 md:space-y-3">
{sorted.map((item: any) => (
<div
key={item.item_name}
className={`group relative w-full bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200/70 duration-200 hover:shadow-md focus-within:ring-slate-300 ${
item.inSafe
? "border-l-4 border-emerald-400"
: "border-l-4 border-red-400 ring-red-200/60 bg-red-50/40"
}`}
>
<div className="flex items-start gap-3">
<span
className="relative mt-0.5 inline-flex"
aria-hidden="true"
>
{!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)}
<span
className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
item.inSafe ? "bg-emerald-500" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe
? "Aktuell im Schließfach"
: "Aktuell nicht im Schließfach"
}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-200/70 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white shadow-sm"></span>
Im Schließfach
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-red-500 rounded-full ring-2 ring-white shadow-sm"></span>
Außerhalb des Schließfachs
</span>
</div>
</div>
</aside>
);

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">
<Sidebar />
</div>
<div className="h-screen flex flex-col bg-slate-50 text-slate-800">
{/* Main */}
<main className="flex-1 flex flex-col items-center py-10 px-4 md:py-14">
<div className="w-full max-w-3xl">
<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">
{children}
<main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8 pb-12">
<div className="w-full max-w-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
<div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
<Sidebar />
</div>
<div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
<div className="w-full">
<Header onLogout={onLogout} />
</div>
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200 flex-1 min-h-0 overflow-y-auto">
{children}
</div>
</div>
</div>
</main>
{/* 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,24 +2,28 @@ 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>
<App />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
theme="light"
style={{ zIndex: 9999 }}
/>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Flip}
/>
</QueryClientProvider>
</StrictMode>
);

View File

@@ -3,18 +3,45 @@ 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;
// First we fetch all items that are potentially available for borrowing
try {
// First we fetch all items that are potentially available for borrowing
const response = await fetch("http://localhost:8002/api/items", {
const response = await fetch("https://backend.insta.the1s.de/api/items", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch items", "error");
return;
@@ -27,11 +54,75 @@ export const fetchAllData = async (token: string | undefined) => {
} catch (error) {
myToast("An error occurred", "error");
}
// get all loans
try {
const response = await fetch("https://backend.insta.the1s.de/api/loans", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("allLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
// get user loans
try {
const response = await fetch("https://backend.insta.the1s.de/api/userLoans", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch user loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("userLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
};
export const loginUser = async (username: string, password: string) => {
try {
const response = await fetch("http://localhost:8002/api/login", {
const response = await fetch("https://backend.insta.the1s.de/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -56,3 +147,47 @@ export const loginUser = async (username: string, password: string) => {
return { success: false } as const;
}
};
export const getBorrowableItems = async () => {
const startDate = Cookies.get("startDate");
const endDate = Cookies.get("endDate");
if (!startDate || !endDate) {
myToast("Bitte wähle einen Zeitraum aus.", "error");
return;
}
try {
const response = await fetch("https://backend.insta.the1s.de/api/borrowableItems", {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ startDate, endDate }),
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch borrowable items", "error");
return;
}
const data = await response.json();
localStorage.setItem("borrowableItems", JSON.stringify(data));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
console.log("Borrowable items fetched successfully");
} catch (error) {
myToast("An error occurred", "error");
}
};

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

@@ -0,0 +1,145 @@
import { myToast } from "./toastify";
import Cookies from "js-cookie";
import { queryClient } from "./queryClient";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try {
const response = await fetch(
`https://backend.insta.the1s.de/api/deleteLoan/${loanID}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Löschen der Ausleihe", "error");
return false;
}
const raw = localStorage.getItem("userLoans");
let current: Array<{ id: number }> = [];
try {
const parsed = raw ? JSON.parse(raw) : [];
current = Array.isArray(parsed) ? parsed : [];
} catch {
current = [];
}
const updated = current.filter(
(loan) => Number(loan.id) !== Number(loanID)
);
localStorage.setItem("userLoans", JSON.stringify(updated));
myToast("Ausleihe erfolgreich gelöscht!", "success");
return true;
} catch (error) {
console.error("Error deleting loan:", error);
myToast("Fehler beim löschen der Ausleihe", "error");
return false;
}
};
// Parse existing cookie and coerce to numbers
let removeArr: number[] = (() => {
try {
const raw = Cookies.get("removeArr");
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n))
: [];
} catch {
return [];
}
})();
const rawCookies = Cookies.withConverter({
write: (value: string) => value, // store raw JSON
});
export const addToRemove = (itemID: number) => {
if (!Number.isFinite(itemID)) return;
if (!removeArr.includes(itemID)) {
removeArr.push(itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
}
};
export const rmFromRemove = (itemID: number) => {
removeArr = removeArr.filter((item) => item !== itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
};
export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr;
const response = await fetch(
"https://backend.insta.the1s.de/api/createLoan",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
body: JSON.stringify({ items, startDate, endDate }),
}
);
if (!response.ok) {
myToast("Fehler beim Erstellen der Ausleihe", "error");
return false;
}
// Clear selection on success
removeArr = [];
Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success");
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
return true;
};
export const onReturn = async (loanID: number) => {
const response = await fetch(
`https://backend.insta.the1s.de/api/returnLoan/${loanID}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Zurückgeben der Ausleihe", "error");
return false;
}
myToast("Ausleihe erfolgreich zurückgegeben!", "success");
return true;
};
export const onTake = async (loanID: number) => {
const response = await fetch(
`https://backend.insta.the1s.de/api/takeLoan/${loanID}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Ausleihen der Ausleihe", "error");
return false;
}
myToast("Ausleihe erfolgreich ausgeliehen!", "success");
return true;
};

View File

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

View File

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