50 Commits

Author SHA1 Message Date
478f03452d added take and return setter buttons 2025-08-28 21:12:58 +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
2cf82c8dcb fixed bug: error handeling 2025-08-20 18:09:33 +02:00
bcf3cc08bb added improved error handeling 2025-08-20 18:06:59 +02:00
cd2b0e8e42 deleted unused notes 2025-08-20 18:06:40 +02:00
4b79583574 added new api endpoint for getting the loan by the loan code 2025-08-20 17:07:49 +02:00
c779a31bfa updated toastify design 2025-08-20 13:38:54 +02:00
c33f3e1101 refactor Layout component for improved sidebar responsiveness 2025-08-20 13:17:23 +02:00
67dd74d3d3 refactor Sidebar component layout for improved responsiveness 2025-08-20 13:09:32 +02:00
d82cde55cc deleted unnecessary imports 2025-08-20 13:06:46 +02:00
2b7f6e8e17 fixed sidebar displaying bug 2025-08-20 13:06:22 +02:00
ccceb5840c fixed sidebar display bug 2025-08-20 12:42:29 +02:00
ff219d850b fixed bug: Now you dont have to reload the page twice, after creating a loan 2025-08-20 12:30:37 +02:00
ef19592b32 refactor docs 2025-08-20 01:29:58 +02:00
8291968e7a maked ui simpler 2025-08-20 01:07:00 +02:00
2480bfab89 feat: update UI components and styles for improved user experience
- Removed obsolete PDF file from the mock directory.
- Updated index.html to change the favicon and title for the application.
- Deleted unused vite.svg file and replaced it with shapes.svg.
- Enhanced App component layout and styling.
- Refined Form1 component with better spacing and updated styles.
- Improved Form2 component to enhance item selection UI and responsiveness.
- Updated Form4 component to improve loan display and interaction.
- Enhanced Header component styling for better visibility.
- Refined LoginForm component for a more modern look.
- Updated Object component styles for better text visibility.
- Improved Sidebar component layout and item display.
- Updated global CSS for better touch target improvements.
- Enhanced Layout component for better responsiveness and structure.
- Updated main.tsx to change toast notification theme.
- Updated tailwind.config.js to include index.html for Tailwind CSS processing.
2025-08-19 23:32:14 +02:00
64bfbecd84 enhance documentation with additional context for item states and API responses 2025-08-19 21:40:28 +02:00
d1494473ef fixed display bug in the table 2025-08-19 21:37:32 +02:00
cbcf282ca3 removed unnesesarry function 2025-08-19 21:26:48 +02:00
c389b38cf5 added new log column "take_date"
Also removed unnesesarry code notes
2025-08-19 21:24:41 +02:00
4080d171cf changed docs accordingly 2025-08-19 21:23:54 +02:00
6d4afa46d7 added more API functions 2025-08-19 21:23:29 +02:00
ffc8fbcefc adjusted data structure 2025-08-19 21:23:15 +02:00
a1435e3280 docs: Remove outdated quick start instructions from README 2025-08-19 19:43:48 +02:00
61c0c8ac96 Overworked docs 2025-08-19 19:42:11 +02:00
6c56c3e46d refactor: Simplify and update backend API documentation for clarity and structure 2025-08-19 19:36:13 +02:00
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
1195e050da Add backend API documentation with current endpoints and item details 2025-08-19 15:09:22 +02:00
2062f2074c Added second external API endpoint and corrosponding file. 2025-08-19 15:09:11 +02:00
76b5599809 changed API docs folder structure 2025-08-19 14:39:37 +02:00
ef78523ccf changed database scheme 2025-08-19 14:38:27 +02:00
8fbcd069b5 Added improved login and logout route for the frontend. The frontend page now doesn't have to reload. 2025-08-19 14:21:30 +02:00
06f84cddc4 fixed sidebar and backend for sidebar 2025-08-19 14:00:00 +02:00
298bc81435 implement user authentication with login functionality and database integration 2025-08-18 21:47:20 +02:00
817a1efcdd added final frontend with full responsive style but without any logic 2025-08-18 18:41:52 +02:00
ea275c0fd0 added Mockups 2025-08-18 17:51:53 +02:00
6dad4abd88 added services folder 2025-08-18 17:19:21 +02:00
634397cc7e changed data sketch 2025-08-18 17:15:58 +02:00
752afd7d88 changed data structure 2025-08-18 16:57:28 +02:00
913aace27f Added Layout and Header component 2025-08-18 16:08:18 +02:00
430f407104 Added docs 2025-08-18 16:08:05 +02:00
39 changed files with 2786 additions and 33 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)

5
Docs/README.md Normal file
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

@@ -0,0 +1,306 @@
# Backend API docs
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
On this page you will learn how my API works.
## General information
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` is for my web frontend, because this file works together with my JWT token service.
**\*But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.**
This is the file that you can use to build an API.
But first you have to get the Admin API key, stored in an `.env` file on my server.
---
## Authentication
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
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
```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
```
#### 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). 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!

276
Mock/AppMockup.tsx Normal file
View File

@@ -0,0 +1,276 @@
import React, { useState } from "react";
// Beispiel-Daten für die Übersicht in der Seitenleiste
const allItems = [
{ id: 1, name: "Kamera" },
{ id: 2, name: "Mikrofon" },
{ id: 3, name: "Licht-Set" },
{ id: 4, name: "Stativ" },
];
// Beispiel-Ausleihen, später per API dynamisch!
const loans = [
{
itemId: 1,
username: "max",
start: "2025-01-01T08:00",
end: "2025-01-01T18:00",
loanCode: "123456",
},
{
itemId: 3,
username: "sara",
start: "2025-01-02T10:00",
end: "2025-01-02T16:00",
loanCode: "654321",
},
];
// Dummy: Für das Beispiel sind einige Items "nicht verfügbar" bei bestimmten Zeiträumen
function getAvailableItems(start: string, end: string) {
if (start.startsWith("2025-01-01")) {
return allItems.filter(
(item) => item.name === "Kamera" || item.name === "Stativ"
);
}
return allItems;
}
export default function App() {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [availableItems, setAvailableItems] = useState<typeof allItems>([]);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
// Dummy Code für das Design
const loanCode = "123456";
return (
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
{/* Seitenleiste */}
<aside className="w-80 min-h-screen bg-white/90 backdrop-blur border-r border-blue-100 shadow-xl flex flex-col p-8">
<h2 className="text-2xl font-extrabold mb-6 text-blue-700 tracking-tight flex items-center gap-2">
<svg
className="w-7 h-7 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>
Ausleih-Übersicht
</h2>
<ul className="space-y-5 flex-1">
{allItems.map((item) => {
const itemLoans = loans.filter((loan) => loan.itemId === item.id);
return (
<li
key={item.id}
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition"
>
<div className="font-semibold text-gray-900 flex items-center gap-2">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background:
itemLoans.length === 0 ? "#34d399" : "#60a5fa",
}}
></span>
{item.name}
</div>
{itemLoans.length === 0 ? (
<div className="text-green-500 text-xs mt-1 font-medium">
Verfügbar
</div>
) : (
<ul className="mt-2 space-y-1">
{itemLoans.map((loan, idx) => (
<li
key={idx}
className="text-xs text-blue-800 bg-blue-100/60 p-1 rounded"
>
<span className="font-bold">{loan.username}</span>
<span className="ml-2">
{formatDateTime(loan.start)} {" "}
{formatDateTime(loan.end)}
</span>
<span className="ml-2 text-gray-400">
({loan.loanCode})
</span>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
<div className="mt-10 text-xs text-gray-400 flex items-center gap-4">
<span className="inline-block w-3 h-3 bg-green-400 rounded-full mr-1"></span>
Verfügbar
<span className="inline-block w-3 h-3 bg-blue-400 rounded-full ml-4 mr-1"></span>
Verliehen
</div>
</aside>
{/* Hauptbereich */}
<main className="flex-1 flex flex-col items-center py-14 px-4">
<header className="mb-12">
<h1 className="text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
Gegenstand ausleihen
</h1>
<p className="text-blue-400 mt-2 text-lg font-medium">
Schnell und unkompliziert Equipment reservieren
</p>
</header>
<div className="bg-white/90 shadow-2xl rounded-3xl p-10 w-full max-w-xl ring-1 ring-blue-100">
{step === 1 && (
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
setAvailableItems(getAvailableItems(startDate, endDate));
setStep(2);
}}
>
<h2 className="text-xl font-bold mb-2 text-blue-700">
1. Zeitraum wählen
</h2>
<div>
<label className="block font-medium mb-1 text-blue-900">
Startdatum
</label>
<input
type="datetime-local"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div>
<label className="block font-medium mb-1 text-blue-900">
Enddatum
</label>
<input
type="datetime-local"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
min={startDate}
/>
</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 disabled:bg-gray-300"
disabled={!startDate || !endDate || endDate <= startDate}
>
Verfügbare Gegenstände anzeigen
</button>
</form>
)}
{step === 2 && (
<div>
<h2 className="text-xl font-bold mb-6 text-blue-700">
2. Gegenstand auswählen
</h2>
{availableItems.length === 0 ? (
<div className="text-red-600 mb-8 font-medium text-center">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<ul className="mb-8 space-y-3">
{availableItems.map((item) => (
<li
key={item.id}
className={`flex justify-between items-center p-4 rounded-xl shadow-sm border ${
selectedItem === item.id
? "bg-blue-100 border-blue-400"
: "bg-green-50 border-green-100 hover:bg-blue-50"
} transition`}
>
<span className="font-medium text-lg">{item.name}</span>
<button
className={`px-4 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-blue-400 text-white text-sm font-semibold shadow hover:from-blue-600 hover:to-blue-500 ${
selectedItem === item.id ? "ring-2 ring-blue-400" : ""
}`}
onClick={() => setSelectedItem(item.id)}
>
{selectedItem === item.id ? "Ausgewählt" : "Auswählen"}
</button>
</li>
))}
</ul>
)}
<div className="flex justify-between">
<button
className="px-5 py-2 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 font-semibold shadow"
onClick={() => setStep(1)}
>
Zurück
</button>
<button
className="px-5 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow transition disabled:bg-gray-300"
disabled={selectedItem === null}
onClick={() => setStep(3)}
>
Ausleihen
</button>
</div>
</div>
)}
{step === 3 && (
<div className="mt-2 p-8 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
<h3 className="font-extrabold text-blue-700 mb-3 text-2xl">
Ausleihe bestätigt!
</h3>
<p className="mb-2 text-lg">
Ihr Ausleih-Code lautet:{" "}
<span className="font-mono text-2xl text-blue-900 bg-white px-2 py-1 rounded shadow">
{loanCode}
</span>
</p>
<p className="mt-2 text-blue-600 text-sm">
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
</p>
<button
className="mt-8 px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow"
onClick={() => {
setStep(1);
setStartDate("");
setEndDate("");
setSelectedItem(null);
}}
>
Neue Ausleihe
</button>
</div>
)}
</div>
</main>
</div>
);
}
// Hilfsfunktion: Datumsformatierung (z.B. 01.01.2025 08:00)
function formatDateTime(dt: string) {
const d = new Date(dt);
return (
d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) +
" " +
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
);
}

View File

@@ -0,0 +1,20 @@
[
{
"id": 1,
"title": "Mock Book 1",
"author": "Author 1",
"description": "Description for Mock Book 1"
},
{
"id": 2,
"title": "Mock Book 2",
"author": "Author 2",
"description": "Description for Mock Book 2"
},
{
"id": 3,
"title": "Mock Book 3",
"author": "Author 3",
"description": "Description for Mock Book 3"
}
]

View File

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

View File

@@ -12,7 +12,9 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0" "express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -34,6 +36,15 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -176,6 +187,15 @@
} }
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -381,6 +401,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -512,6 +541,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.4", "version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
@@ -529,6 +564,45 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/jose": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/lru.min": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
"integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -598,6 +672,38 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz",
"integrity": "sha512-fD6MLV8XJ1KiNFIF0bS7Msl8eZyhlTDCDl75ajU5SJtpdx9ZPEACulJcqJWr1Y8OYyxsFc4j3+nflpmhxCU5aQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -789,6 +895,11 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
@@ -882,6 +993,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -14,6 +14,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0" "express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
} }
} }

169
backend/routes/api.js Normal file
View File

@@ -0,0 +1,169 @@
import express from "express";
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();
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
});
router.get("/items", authenticate, async (req, res) => {
console.log(req);
const result = await getItemsFromDatabase(req.user.role);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
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;

93
backend/routes/apiV2.js Normal file
View File

@@ -0,0 +1,93 @@
import express from "express";
import dotenv from "dotenv";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
getLoanByCodeV2,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
// Route for API to get ALL items from the database
router.get("/items/:key", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
// 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

@@ -0,0 +1,80 @@
-- All necessary tables for the borrowing system
-- IMPORTANT: You need mySQL version 8.0 or newer!
CREATE TABLE `users` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`role` int DEFAULT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `loans` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`loan_code` int NOT NULL,
`start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL,
`take_date` timestamp NULL DEFAULT NULL,
`returned_date` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`)
);
CREATE TABLE `items` (
`id` int NOT NULL AUTO_INCREMENT,
`item_name` varchar(255) NOT NULL,
`can_borrow_role` INT NOT NULL,
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item_name` (`item_name`)
);
CREATE TABLE `lockers` (
`id` int NOT NULL AUTO_INCREMENT,
`item` varchar(255) NOT NULL,
`locker_number` int NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item` (`item`),
UNIQUE KEY `locker_number` (`locker_number`)
);
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1),
('DJI 2er Mikro 2', 4, 1),
('Rode Richt Mikrofon', 2, 1),
('Kamera Stativ', 1, 0),
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
('MacBook inkl. Adapter', 2, 0),
('SD Karten', 3, 0),
('Kameragimbal', 1, 0),
('ATEM MINI PRO', 1, 1),
('Handygimbal', 4, 0),
('Kameralüfter', 1, 1),
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('DJI 1er Mikro', 1),
('DJI 2er Mikro 1', 2),
('DJI 2er Mikro 2', 3),
('Rode Richt Mikrofon', 4),
('Kamera Stativ', 5),
('SONY Kamera - inkl. Akkus und Objektiv', 6),
('MacBook inkl. Adapter', 7),
('SD Karten', 8),
('Kameragimbal', 9),
('ATEM MINI PRO', 10),
('Handygimbal', 11),
('Kameralüfter', 12),
('Kleine Kamera 1 - inkl. Objektiv', 13),
('Kleine Kamera 2 - inkl. Objektiv', 14);

View File

@@ -1,6 +1,8 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import env from "dotenv"; import env from "dotenv";
import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8002; const port = 8002;
@@ -11,6 +13,9 @@ app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
app.use("/api", apiRouter);
app.use("/apiV2", apiRouterV2);
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.render("index.ejs"); res.render("index.ejs");
}); });

View File

@@ -0,0 +1,321 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT * 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
? "SELECT * FROM items;"
: "SELECT * FROM items WHERE can_borrow_role >= ?";
const params = role == 0 ? [] : [role];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
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

@@ -0,0 +1,26 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
env.config();
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
export async function generateToken(payload) {
const newToken = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h") // Token valid for 2 hours
.sign(secret);
console.log("Generated token: ", newToken);
return newToken;
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer <token>
if (token == null) return res.sendStatus(401); // No token present
const { payload } = await jwtVerify(token, secret);
req.user = payload;
next();
}

View File

@@ -19,7 +19,7 @@ services:
environment: environment:
DB_HOST: mysql DB_HOST: mysql
DB_USER: root DB_USER: root
DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: borrow_system DB_NAME: borrow_system
depends_on: depends_on:
- mysql - mysql
@@ -32,7 +32,7 @@ services:
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: borrow_system MYSQL_DATABASE: borrow_system
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql

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

@@ -1,10 +1,61 @@
import "./App.css"; import "./App.css";
import Layout from "./layout/Layout";
import { useEffect, useState } from "react";
import Form1 from "./components/Form1";
import Form2 from "./components/Form2";
import Form4 from "./components/Form4";
import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie";
import {
fetchAllData,
ALL_ITEMS_UPDATED_EVENT,
AUTH_LOGOUT_EVENT,
} from "./utils/fetchData";
import { myToast } from "./utils/toastify";
function App() { function App() {
return ( const [isLoggedIn, setIsLoggedIn] = useState(false);
<>
<h1 className="text-3xl font-bold underline">Hello world!</h1> useEffect(() => {
</> const token = Cookies.get("token");
if (token) {
setIsLoggedIn(true);
fetchAllData(token);
}
localStorage.setItem("borrowableItems", JSON.stringify([]));
}, []);
useEffect(() => {
const onAuthLogout = () => {
setIsLoggedIn(false);
};
window.addEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
return () => window.removeEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
}, []);
const handleLogout = () => {
Cookies.remove("token");
localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success");
setIsLoggedIn(false);
};
return isLoggedIn ? (
<Layout onLogout={handleLogout}>
<div className="space-y-6">
<Form1 />
<div className="h-px bg-slate-200" />
<Form2 />
<div className="h-px bg-slate-200" />
<Form4 />
</div>
</Layout>
) : (
<LoginForm onLogin={() => setIsLoggedIn(true)} />
); );
} }

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

@@ -0,0 +1,65 @@
import React from "react";
import Cookies from "js-cookie";
import { getBorrowableItems } from "../utils/fetchData";
const Form1: React.FC = () => {
return (
<div className="space-y-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
1. Zeitraum wählen
</h2>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const fd = new FormData(form);
const start = (fd.get("startDate") as string) || "";
const end = (fd.get("endDate") as string) || "";
Cookies.set("startDate", start);
Cookies.set("endDate", end);
getBorrowableItems();
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
htmlFor="startDate"
className="block text-sm font-medium text-slate-700 mb-1"
>
Start
</label>
<input
type="datetime-local"
id="startDate"
name="startDate"
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
/>
</div>
<div>
<label
htmlFor="endDate"
className="block text-sm font-medium text-slate-700 mb-1"
>
Ende
</label>
<input
type="datetime-local"
id="endDate"
name="endDate"
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
/>
</div>
</div>
<button
type="submit"
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
>
Verfügbare Gegenstände anzeigen
</button>
</form>
</div>
);
};
export default Form1;

View File

@@ -0,0 +1,186 @@
import React from "react";
import Cookies from "js-cookie";
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
}
const LOCAL_STORAGE_KEY = "borrowableItems";
function normalizeBorrowable(data: any): BorrowItem[] {
const rawArr = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: Array.isArray(data?.data)
? data.data
: [];
return rawArr
.map((raw: any) => {
const idRaw =
raw.id ?? raw.item_id ?? raw.itemId ?? raw.itemID ?? raw.itemIdPk;
const id = Number(idRaw);
const item_name = String(raw.item_name ?? raw.name ?? raw.title ?? "");
const can_borrow_role = String(
raw.can_borrow_role ?? raw.role ?? raw.requiredRole ?? ""
);
const inSafeRaw =
raw.inSafe ?? raw.in_safe ?? raw.inLocker ?? raw.isInSafe ?? raw.safe;
const inSafe =
typeof inSafeRaw === "boolean"
? Number(inSafeRaw)
: Number(isNaN(Number(inSafeRaw)) ? 0 : Number(inSafeRaw));
if (!Number.isFinite(id) || !item_name) return null;
return { id, item_name, can_borrow_role, inSafe };
})
.filter(Boolean) as BorrowItem[];
}
function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]);
const readFromStorage = React.useCallback(() => {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]";
const parsed = JSON.parse(raw);
const arr = normalizeBorrowable(parsed);
setItems(arr);
} catch {
setItems([]);
}
}, []);
React.useEffect(() => {
readFromStorage();
const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
};
window.addEventListener("storage", onStorage);
const onBorrowableUpdated = () => readFromStorage();
window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
};
}, [readFromStorage]);
return items;
}
const Form2: React.FC = () => {
const items = useBorrowableItems();
return (
<div className="space-y-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
2. Gegenstand auswählen
</h2>
{items.length === 0 ? (
<div className="text-slate-700 text-center bg-slate-100 border border-slate-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<>
{/* Mobile: card list */}
<div className="sm:hidden space-y-2">
{items.map((item) => (
<label
key={item.id}
htmlFor={`item-${item.id}`}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-200 bg-white shadow-sm"
>
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 truncate">
{item.item_name}
</div>
<div className="text-xs text-slate-500">
{item.inSafe ? "Verfügbar" : "Nicht im Schließfach"}
</div>
</div>
<input
type="checkbox"
id={`item-${item.id}`}
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
className="h-5 w-5 accent-indigo-600"
/>
</label>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-slate-200 shadow-sm bg-white">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
Gegenstand
</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
<input type="checkbox" className="invisible" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-sm font-medium text-slate-900">
{item.item_name}
</td>
<td className="px-4 py-2 text-sm text-slate-700 text-right">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
id={`item-${item.id}`}
className="h-4 w-4 accent-indigo-600"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-1">
<button
onClick={() => {
createLoan(
Cookies.get("startDate") ?? "",
Cookies.get("endDate") ?? ""
);
}}
type="button"
className="w-full sm:w-44 bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
>
Ausleihen
</button>
</div>
</div>
);
};
export default Form2;

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("http://localhost:8002/api/userLoans", {
method: "GET",
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
});
if (!res.ok) throw new Error("Failed to fetch user loans");
const data = await res.json();
if (data === "No loans found for this user") return [];
return Array.isArray(data) ? (data as Loan[]) : [];
}
const Form4: React.FC = () => {
const { 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

@@ -0,0 +1,41 @@
import React from "react";
type HeaderProps = {
onLogout: () => void;
};
const Header: React.FC<HeaderProps> = ({ onLogout }) => {
return (
<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>
);
};
export default Header;

View File

@@ -0,0 +1,75 @@
import React from "react";
import Footer from "./Footer";
import { useState } from "react";
import { loginUser } from "../utils/fetchData";
import { myToast } from "../utils/toastify";
type LoginFormProps = {
onLogin: () => void;
};
const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await loginUser(username, password);
if (result.success) {
onLogin();
} else {
myToast("Login failed. Please check your credentials.", "error");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-6 sm:p-8 border border-slate-200">
<h2 className="text-2xl font-bold text-slate-900 mb-6 text-center">
Login
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-slate-700 mb-1"
>
Username
</label>
<input
type="text"
onChange={(e) => setUsername(e.target.value)}
id="username"
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700 mb-1"
>
Password
</label>
<input
onChange={(e) => setPassword(e.target.value)}
type="password"
id="password"
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required
/>
</div>
<button
type="submit"
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-md shadow hover:bg-indigo-700 transition"
>
Login
</button>
</form>
</div>
<Footer />
</div>
);
};
export default LoginForm;

View File

@@ -0,0 +1,17 @@
import React from "react";
type ObjectProps = {
title: string;
description: string;
};
const Object: React.FC<ObjectProps> = ({ title, description }) => {
return (
<div className="min-w-0">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="text-xs text-slate-600 line-clamp-2">{description}</p>
</div>
);
};
export default Object;

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useState } from "react";
import Object from "./Object";
import { MonitorSmartphone } from "lucide-react";
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
const Sidebar: React.FC = () => {
const [items, setItems] = useState<any[]>(
JSON.parse(localStorage.getItem("allItems") || "[]")
);
useEffect(() => {
const handler = () => {
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
setItems(next);
};
handler();
window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
}, []);
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
return (
<aside className="w-full md:w-72 md:h-full flex flex-col rounded-2xl pt-0 px-3 pb-3 sm:pt-0 sm:px-4 sm:pb-4 bg-gradient-to-b from-white to-slate-50 ring-1 ring-slate-200/70 shadow-md overflow-hidden">
<div className="sticky top-0 z-10 -mx-3 sm:-mx-4 px-3 sm:px-4 py-2.5 bg-white/85 backdrop-blur supports-[backdrop-filter]:backdrop-blur border-b border-slate-200/70 text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between gap-2 rounded-t-2xl">
<span className="flex items-center gap-2 min-w-0 flex-1 truncate">
<MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
<span className="truncate">Geräte</span>
</span>
{outCount > 0 && (
<span className="inline-flex items-center gap-1 whitespace-nowrap tabular-nums text-[10px] sm:text-xs px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 ring-1 ring-amber-200/70 shadow-sm font-medium">
{outCount} außerhalb
</span>
)}
</div>
{/* Scroll area */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-3 md:space-y-3">
{sorted.map((item: any) => (
<div
key={item.item_name}
className={`group relative w-full bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200/70 duration-200 hover:shadow-md focus-within:ring-slate-300 ${
item.inSafe
? "border-l-4 border-emerald-400"
: "border-l-4 border-red-400 ring-red-200/60 bg-red-50/40"
}`}
>
<div className="flex items-start gap-3">
<span
className="relative mt-0.5 inline-flex"
aria-hidden="true"
>
{!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)}
<span
className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
item.inSafe ? "bg-emerald-500" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe
? "Aktuell im Schließfach"
: "Aktuell nicht im Schließfach"
}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-200/70 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white shadow-sm"></span>
Im Schließfach
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-red-500 rounded-full ring-2 ring-white shadow-sm"></span>
Außerhalb des Schließfachs
</span>
</div>
</div>
</aside>
);
};
export default Sidebar;

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

@@ -0,0 +1,36 @@
import React from "react";
import "../App.css";
import Header from "../components/Header";
import Sidebar from "../components/Sidebar";
import Footer from "../components/Footer";
type LayoutProps = {
children: React.ReactNode;
onLogout: () => void;
};
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return (
<div className="h-screen flex flex-col bg-slate-50 text-slate-800">
{/* Main */}
<main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8 pb-12">
<div className="w-full max-w-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
<div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
<Sidebar />
</div>
<div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
<div className="w-full">
<Header onLogout={onLogout} />
</div>
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200 flex-1 min-h-0 overflow-y-auto">
{children}
</div>
</div>
</div>
</main>
<Footer />
</div>
);
};
export default Layout;

View File

@@ -1,10 +1,29 @@
import { StrictMode } from 'react' 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, 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( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
</StrictMode>, <App />
) <ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Flip}
/>
</QueryClientProvider>
</StrictMode>
);

View File

@@ -0,0 +1,193 @@
import Cookies from "js-cookie";
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 {
const response = await fetch("http://localhost:8002/api/items", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch items", "error");
return;
}
const data = await response.json();
localStorage.setItem("allItems", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that items have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
// get all loans
try {
const response = await fetch("http://localhost:8002/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("http://localhost:8002/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", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return { success: false } as const;
}
const data = await response.json();
if (data?.token) {
Cookies.set("token", data.token);
myToast("Login successful!", "success");
fetchAllData(Cookies.get("token"));
return { success: true, token: data.token } as const;
}
return { success: false } as const;
} catch (e) {
return { success: false } as const;
}
};
export const getBorrowableItems = async () => {
const startDate = Cookies.get("startDate");
const endDate = Cookies.get("endDate");
if (!startDate || !endDate) {
myToast("Bitte wähle einen Zeitraum aus.", "error");
return;
}
try {
const response = await fetch("http://localhost:8002/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

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

View File

@@ -0,0 +1,139 @@
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(
`http://localhost:8002/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("http://localhost:8002/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(
`http://localhost:8002/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(`http://localhost:8002/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 = { 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
], ],

Binary file not shown.