44 Commits

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

2
.gitignore vendored
View File

@@ -112,3 +112,5 @@ backend/public/uploads/
config/ config/
secrets/ secrets/
keys/ keys/
ToDo.txt

11
Docs/HELP.md Normal file
View File

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

View File

@@ -8,7 +8,7 @@ On this page you will learn how my API works.
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. 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`. **\*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. This is the file that you can use to build an API.
@@ -24,6 +24,16 @@ 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 ## Current endpoints
### 1. Get All Items ### 1. Get All Items
@@ -35,21 +45,114 @@ Returns a list of all items and their details.
#### Example Request #### Example Request
``` ```
GET /apiV2/items/your_admin_key GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
``` ```
#### Example Response #### Example Response
``` ```
[ {
"data": [
{ {
"id": 1, "id": 1,
"item_name": "DJI 1er Mikro", "item_name": "DJI 1er Mikro",
"can_borrow_role": "4", "can_borrow_role": 4,
"inSafe": 1 "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: Each item has the following properties:
@@ -57,7 +160,9 @@ Each item has the following properties:
- `id`: The unique identifier for the item. - `id`: The unique identifier for the item.
- `item_name`: The name of the item. - `item_name`: The name of the item.
- `can_borrow_role`: The role ID that is allowed to borrow 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._
--- ---
@@ -72,14 +177,119 @@ Updates the `inSafe` state of an item (whether it is in the locker).
#### Example Request #### Example Request
``` ```
POST /apiV2/controlInSafe/your_admin_key/5/0 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 #### Example Response
``` ```
{ {
"message": "Item state updated successfully" "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"
} }
``` ```

Binary file not shown.

View File

@@ -1,73 +1,7 @@
# Borrow System # Borrow System
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB) **You have reached the `debian12` branch.**
![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. Here you will find the source code of exactly the application that I have hosted.
- Frontend: React + TypeScript + Vite + Tailwind CSS The main branch or the branch that I am developing on, is the `dev` branch.
- 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -0,0 +1,12 @@
import React from "react";
const Footer: React.FC = () => {
return (
<footer className="fixed bottom-0 left-0 text-sm w-full bg-slate-100 text-center py-2 border-t border-slate-200 z-50">
<p>Made with by Theis Gaedigk - Jahrgang 2019</p>
<p>v1.1</p>
</footer>
);
};
export default Footer;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import React from "react";
import "../App.css"; import "../App.css";
import Header from "../components/Header"; import Header from "../components/Header";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import Footer from "../components/Footer";
type LayoutProps = { type LayoutProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -10,28 +11,24 @@ type LayoutProps = {
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => { const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return ( return (
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100"> <div className="h-screen flex flex-col bg-slate-50 text-slate-800">
{/* Sidebar */} {/* Main */}
<div className="hidden md:block"> <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 /> <Sidebar />
</div> </div>
<div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
{/* Main */} <div className="w-full">
<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} /> <Header onLogout={onLogout} />
</div> </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} {children}
</div> </div>
</div>
</div>
</main> </main>
<Footer />
{/* Mobile sidebar at bottom */}
<div className="fixed bottom-4 left-4 right-4 md:hidden z-10">
<div className="bg-white/95 backdrop-blur rounded-2xl shadow-xl ring-1 ring-blue-100 p-4">
<Sidebar />
</div>
</div>
</div> </div>
); );
}; };

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
// Central QueryClient instance so utilities (e.g. file upload) can invalidate queries.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});

View File

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

View File

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

View File

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

View File

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