96 Commits

Author SHA1 Message Date
5ba35bb471 remove unused mock data files and update docker-compose paths 2025-10-25 22:44:43 +02:00
47b5590394 removed landingpage from admin panel 2025-10-25 22:43:16 +02:00
47fec60b5b added landingpage and fixed routing 2025-10-25 22:41:43 +02:00
e9319b49ec enhanced Header component with mobile menu and password change dialog; updated HomePage layout 2025-10-25 22:31:54 +02:00
b98e38b38b enhanced MyLoansPage with confirmation dialog for loan deletion; improved table layout and added code formatting for loan codes 2025-10-25 22:11:44 +02:00
a24a3033d3 updated scheme 2025-10-25 21:53:08 +02:00
d94d68aa33 added password input component and integrated password change functionality in Header; updated LoginPage for localized labels 2025-10-25 21:49:28 +02:00
cc0dcaf664 added MyLoansPage component and integrated loan deletion functionality; updated routing in App and added Header component 2025-10-25 21:27:08 +02:00
7a79bf4436 added loan creation functionality and improved error handling in HomePage and Fetcher 2025-10-25 20:19:11 +02:00
4b00dd6554 added borrowable items fetching and date input functionality to HomePage 2025-10-25 20:01:06 +02:00
ba34a97328 added sf pro 2025-10-25 19:07:26 +02:00
a013ad0bb8 enhanced greeting 2025-10-25 16:47:32 +02:00
d7240584f9 added logout function and enhanced greeting 2025-10-25 16:44:30 +02:00
a0bdf5539c added greeting with context logic 2025-10-25 15:59:52 +02:00
770025f8fc added changelog mock (not yet functionaning) 2025-10-25 00:23:56 +02:00
960e91c38a feat: Implement authentication flow with token verification and protected routes 2025-10-24 20:45:37 +02:00
b99f52f09a feat: Initialize FrontendV2 project with React, Vite, and Tailwind CSS
- Add package.json with dependencies and scripts for development and build
- Include Vite logo and React logo SVGs in public/assets
- Set up Tailwind CSS in App.css and index.css
- Create main App component with routing for Home and Login pages
- Implement LoginPage with authentication logic and error handling
- Add HomePage component as a landing page
- Create MyAlert component for displaying alerts using Chakra UI
- Implement color mode toggle functionality with Chakra UI
- Set up global state management using Jotai for authentication
- Create ProtectedRoutes component to guard routes based on authentication
- Add utility components for Toaster and Tooltip using Chakra UI
- Configure Tailwind CSS and TypeScript settings for the project
- Implement AddLoan component for selecting loan periods and fetching available items
2025-10-24 20:21:32 +02:00
86af1a5edf implemented jotai atoms 2025-10-22 21:44:05 +02:00
49d4d13afc improved design of email 2025-10-05 15:55:05 +02:00
45fa095eaf fixed mail view issues 2025-10-05 15:49:38 +02:00
23be7e12c7 removed some logging stuff 2025-10-04 19:34:27 +02:00
6ea1ff799c added .env link function 2025-10-04 00:26:02 +02:00
5131266242 changed itemform placeholder for better understanding 2025-10-04 00:08:19 +02:00
bb17bc735c added landingpage to email 2025-10-04 00:07:32 +02:00
af7d15c97a add nodemailer integration for loan email notifications and implement loan info retrieval 2025-10-02 22:32:26 +02:00
04453fd885 improved design of the error message 2025-09-30 13:06:49 +02:00
bf36a6605f improved error handling for adding an item 2025-09-30 13:03:00 +02:00
8f9696991f improved error handling 2025-09-30 12:59:38 +02:00
9cad1e8b6b fixed minor display bugs 2025-09-30 12:53:58 +02:00
880029a0cf changed docs 2025-09-29 10:53:50 +02:00
32abe60d98 fixed api route for setting the return and take date 2025-09-29 10:44:51 +02:00
ea965971f1 Refactor Dashboard and Login components: add URL synchronization in Dashboard, update Login form submission handling 2025-09-28 16:07:39 +02:00
eff1f61422 Refactor API routes: remove API key requirement for allLoans and allItems endpoints, update comments for clarity 2025-09-27 23:29:19 +02:00
a4c0323100 changed docs 2025-09-27 23:29:08 +02:00
fc755edadf fixed scheme 2025-09-27 23:06:21 +02:00
0fca896cc2 Refactor API key validation: streamline error handling and enforce API key presence in routes 2025-09-27 22:54:15 +02:00
f83f321876 NOT WORKING - Implement API key management features: add API key creation and deletion, update API routes, and refactor related components. - NOT WORKING 2025-09-27 17:33:59 +02:00
b9d637665c changed data scheme 2025-09-27 16:40:36 +02:00
378720b235 created APIKeyTable 2025-09-27 16:37:57 +02:00
49f4ba8483 added itemview to landingpage 2025-09-22 13:22:27 +02:00
ea5d31384a added a bit documentation to the api file 2025-09-21 10:45:31 +02:00
7f5f464841 changed design for the landingpage 2025-09-21 10:45:16 +02:00
ab93c9959d fullfilled landingpage 2025-09-21 00:48:28 +02:00
679ef7dcbd feat: implement Landingpage component and update Layout to conditionally render it 2025-09-19 12:24:17 +02:00
c3572a3d70 fix: adjust icon placement and styling for safe state indicator 2025-09-16 13:49:44 +02:00
4fc60a08d9 refactor: update button for safe state with improved styling and text display 2025-09-16 13:48:56 +02:00
5159877d8d added changeSafeState function 2025-09-16 13:00:15 +02:00
b3ddfd9aa5 added working item change route 2025-09-16 11:13:19 +02:00
755ebfd06b added inpu elemts and backend API routes for changing the item table 2025-09-11 16:40:31 +02:00
e198fce791 fixed generated code 2025-09-08 19:21:31 +02:00
8341404f45 changed timezone 2025-09-05 11:27:13 +02:00
5291752403 fixed bug: onReturn & onTake functions are know functioaning 2025-09-03 15:24:04 +02:00
c0ae12185a redesgined header 2025-09-03 14:52:36 +02:00
68f13f369c add password change functionality with frontend integration 2025-09-03 14:50:35 +02:00
a24b2697b0 enhance loan handling by updating item states on take and return 2025-09-03 14:33:54 +02:00
13a654561e refactor user editing functionality to remove password handling 2025-09-03 14:25:12 +02:00
5a058de2f0 refactor error mgmt for creating new password 2025-09-03 14:16:25 +02:00
b8f13a37fd added function to change user password with the admin panel 2025-09-03 14:10:42 +02:00
423075e746 fixed bug in admin panel 2025-09-03 13:42:47 +02:00
99810d2b7d fixed bug/issue: #9 2025-09-03 13:40:55 +02:00
a1972f26d3 fixed bug/issue: #8 2025-09-03 13:16:51 +02:00
866f860d5a fixed bug/issue: #11 2025-09-03 13:14:30 +02:00
16e1dca43c fixed issue/bug: #10 2025-09-03 13:12:46 +02:00
c47c311ecd fixed issue/bug: #7 2025-09-03 13:11:03 +02:00
bd504f7817 fix: update favicon to user-star.svg and remove vite.svg; enhance Login component layout 2025-09-02 20:55:29 +02:00
b0914314bb deleted unnesecarry syntax 2025-09-02 20:51:02 +02:00
48c16350b7 refactor: remove LockerTable component, enhance ItemTable and LoanTable with CRUD functionality, and implement AddItemForm for item creation 2025-09-02 20:30:29 +02:00
b217769961 enhance dashboard and user interface: update heading sizes, translate user label to German, and implement loan management features including fetching and displaying loans with error handling 2025-09-02 18:51:41 +02:00
769d1117eb added changelog 2025-08-31 20:08:37 +02:00
c77bef5cf3 add user management features: implement user creation, editing, and deletion; enhance dashboard with user selection prompt; improve token verification and alert handling 2025-08-31 20:02:51 +02:00
217803ba8f implement admin panel with login functionality and dashboard layout 2025-08-31 18:07:49 +02:00
8fb70ccccd added docker implementation for admin panel 2025-08-31 14:55:33 +02:00
311de4f78b added chakra ui 2025-08-31 14:53:42 +02:00
7b36514e27 changed infos and added some more packages 2025-08-28 21:35:49 +02:00
7be418ad75 created admin panel folder 2025-08-28 21:32:04 +02:00
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
112 changed files with 18914 additions and 596 deletions

2
.gitignore vendored
View File

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

22
Docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,22 @@
# Changelog
v1.1
## Current hosted version
v1.1
> No changelog available.
## Upcoming changes
v1.2
### Fixes and improvements
- Implement user roles and permissions
- Improve form validation and error handling
- Add loading indicators for async actions
- Optimize performance for large datasets
### New features
- Admin panel for managing users, permissions and all of the system settings and database

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

@@ -1,4 +1,4 @@
# Backend API docs
# Backend API docs (apiV2)
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
@@ -6,145 +6,205 @@ 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.
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` which 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 an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
But first you have to get the Admin API key, stored in an `.env` file on my server.
---
## Base URL
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
You can see the status of this and all my other services at `https://status.the1s.de`.
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
---
## Authentication
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
All endpoints require an API key as a path parameter named `:key`.
Example: `/apiV2/items/{ADMIN_ID}`
Example: `/apiV2/items/:key`
If the key is missing or invalid, the API responds with `401 Unauthorized`.
---
## Current endpoints
## Endpoints
### 1. Get All Items
### 1) Get all items
**GET** `/apiV2/items/:key`
GET `/apiV2/items/:key`
Returns a list of all items and their details.
Returns a list of all items wrapped in a `data` object.
#### Example Request
Example request:
```
GET /apiV2/items/your_admin_key
GET https://backend.insta.the1s.de/apiV2/items/12345
```
#### Example Response
Example response:
```
[
{
"data": [
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": "4",
"inSafe": 1
},
...
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
}
]
}
```
Each item has the following properties:
Fields:
- `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.
- `id`: Unique identifier
- `item_name`: Item name
- `can_borrow_role`: Role allowed to borrow
- `inSafe`: 1 if in locker, 0 otherwise
- `entry_created_at`: Creation timestamp
Status: 200 on success, 500 on failure.
---
### 2. Change Item Safe State
### 2) Change item safe state
**POST** `/apiV2/controlInSafe/:key/:itemId/:state`
POST `/apiV2/controlInSafe/:key/:itemId/:state`
Updates the `inSafe` state of an item (whether it is in the locker).
Updates `inSafe` (locker) state of an item.
- `state` must be `"1"` (in safe) or `"0"` (not in safe).
- `state` must be `"1"` (in safe) or `"0"` (not in safe)
#### Example Request
Example request:
```
POST /apiV2/controlInSafe/your_admin_key/5/0
POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
```
#### Example Response
Example response (shape depends on database service):
```
{}
{ "data": { /* update result */ } }
```
*An empty object means, that the operation was successful and no further information is returned.*
Status:
*You also get an http 2xx status code.*
- 200 on success
- 400 if `state` is invalid
- 500 on failure
**You can get the item id on the admin panel, from your system administrator.**
---
### 3. Set Return Date
### 3) Get loan by code
**POST** `/apiV2/setReturnDate/:key/:loan_code`
GET `/apiV2/getLoanByCode/:key/:loan_code`
Sets the `returned_date` of a loan to the current server time.
Retrieves the details of a specific loan.
- `loan_code`: The unique code of the loan.
#### Example Request
Example request:
```
POST /apiV2/setReturnDate/your_admin_key/123456
GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
```
#### Example Response
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"]
}
}
```
*An empty object means, that the operation was successful and no further information is returned.*
Status:
*You also get an http 2xx status code.*
- 200 on success
- 404 if not found
---
### 4. Set Take Date
### 4) Set return date (now) by loan code
**POST** `/apiV2/setTakeDate/:key/:loan_code`
POST `/apiV2/setReturnDate/:key/:loan_code`
Sets the `take_date` of a loan to the current server time.
Sets the `returned_date` to the current server time.
- `loan_code`: The unique code of the loan.
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
#### Example Request
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
Example request:
```
POST /apiV2/setTakeDate/your_admin_key/123456
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
```
#### Example Response
Example response:
```
{}
{ "data": { /* update result */ } }
```
*An empty object means, that the operation was successful and no further information is returned.*
*You also get an http 2xx status code.*
Status: 200 on success, 500 on failure.
---
## Error Handling
### 5) Set take date (now) by loan code
- `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.
POST `/apiV2/setTakeDate/:key/:loan_code`
Sets the `take_date` to the current server time.
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
Example request:
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
```
Example response:
```
{ "data": { /* update result */ } }
```
Status: 200 on success, 500 on failure.
---
If you have questions or want to collaborate, please reach out to me!
## Error handling
- 401 Unauthorized: Missing or invalid API key
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
- 404 Not Found: Loan not found
- 500 Internal Server Error: Database or server error
---
If you have questions or want to collaborate, please reach out!

24
FrontendV2/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
FrontendV2/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8001
CMD ["npm", "run", "dev"]

73
FrontendV2/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
FrontendV2/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontendv2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6045
FrontendV2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
FrontendV2/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.0",
"react-toastify": "^11.0.5",
"split-lines": "^3.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.5",
"vite-plugin-svgr": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

73
FrontendV2/src/App.css Normal file
View File

@@ -0,0 +1,73 @@
@import "tailwindcss";
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}
/* Display für größere Überschriften */
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Text für Fließtext */
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Global anwenden mit Fallbacks */
:root {
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}

68
FrontendV2/src/App.tsx Normal file
View File

@@ -0,0 +1,68 @@
import "./App.css";
import { LoginPage } from "@/pages/LoginPage";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { HomePage } from "@/pages/HomePage";
import { ProtectedRoutes } from "./utils/ProtectedRoutes";
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useAtom } from "jotai";
import { setIsLoggedInAtom } from "@/states/Atoms";
import { UserContext, type User } from "./states/Context";
import { triggerLogoutAtom } from "@/states/Atoms";
import { MyLoansPage } from "./pages/MyLoansPage";
import Landingpage from "./pages/Landingpage";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
function App() {
const [user, setUser] = useState<User | undefined>(undefined);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
useEffect(() => {
if (Cookies.get("token")) {
const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (response.ok) {
setTriggerLogout(false);
const data = await response.json();
setUser({ username: data.user.username, role: data.user.role });
setIsLoggedIn(true);
} else {
Cookies.remove("token");
setIsLoggedIn(false);
window.location.reload();
}
};
verifyToken();
}
}, []);
return (
<>
<UserContext.Provider value={user}>
<BrowserRouter>
<Routes>
<Route element={<ProtectedRoutes />}>
<Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landing" element={<Landingpage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
</BrowserRouter>
</UserContext.Provider>
</>
);
}
export default App;

View File

@@ -0,0 +1 @@
<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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,50 @@
{
"title": "Changelog",
"items": [
{
"version": "v2.1.0",
"date": "2025-10-24",
"changes": [
{
"type": "Hinzugefügt",
"text": [
"Neue Changelog-Komponente mit zentriertem Layout.",
"Unterstützung für mehrsprachige Einträge (Englisch und Deutsch)."
]
},
{
"type": "Verbessert",
"text": [
"Performance-Optimierungen beim Laden der Listenansichten.",
"Verbesserte Barrierefreiheit durch ARIA-Attribute."
]
},
{
"type": "Behoben",
"text": [
"Fehler bei der Datumsauswahl im Safari-Browser.",
"Anzeigeprobleme bei hohen DPI-Einstellungen."
]
}
]
},
{
"version": "v2.0.3",
"date": "2025-10-10",
"changes": [
{
"type": "Geändert",
"text": [
"Standard-Timeout für API-Requests auf 10s erhöht."
]
},
{
"type": "Sicherheit",
"text": [
"Abhängigkeiten aktualisiert (kritische CVEs behoben)."
]
}
]
}
]
}

View File

@@ -0,0 +1,263 @@
import { useEffect, useRef, useState } from "react";
const STORAGE_KEY = "changelog";
type ChangeType =
| "Hinzugefügt"
| "Geändert"
| "Behoben"
| "Entfernt"
| "Verbessert"
| "Sicherheit"
| "Veraltet"
| string;
type ChangeEntry = {
type: ChangeType;
text: string | string[]; // aus localStorage kann es eine Liste sein
};
type ChangelogItem = {
version?: string;
date: string;
changes: ChangeEntry[];
};
type StoredChangelog = {
title: string;
items: ChangelogItem[];
};
const typeStyles: Record<string, string> = {
Hinzugefügt:
"bg-emerald-500/15 text-emerald-300 ring-1 ring-inset ring-emerald-500/30",
Geändert: "bg-blue-500/15 text-blue-300 ring-1 ring-inset ring-blue-500/30",
Behoben: "bg-amber-500/15 text-amber-300 ring-1 ring-inset ring-amber-500/30",
Entfernt: "bg-rose-500/15 text-rose-300 ring-1 ring-inset ring-rose-500/30",
Verbessert:
"bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30",
Sicherheit: "bg-red-500/15 text-red-300 ring-1 ring-inset ring-red-500/30",
Veraltet: "bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40",
};
export default function Changelog() {
const [open, setOpen] = useState(true);
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<StoredChangelog | null>(null);
const [error, setError] = useState<string | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setMounted(true), []);
const loadFromStorage = () => {
try {
setError(null);
const raw =
typeof window !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (!raw) {
setData(null);
return;
}
const parsed = JSON.parse(raw) as StoredChangelog;
if (!parsed || !Array.isArray(parsed.items)) {
throw new Error("Ungültiges Format");
}
setData(parsed);
} catch (e) {
setError("Changelog konnte nicht aus localStorage geladen werden.");
setData(null);
}
};
useEffect(() => {
loadFromStorage();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onClickOutside = (e: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) loadFromStorage();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClickOutside);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClickOutside);
window.removeEventListener("storage", onStorage);
};
}, []);
if (!open) return null;
const title = data?.title ?? "Changelog";
const items = data?.items ?? [];
return (
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(60%_60%_at_50%_0%,rgba(99,102,241,0.12),rgba(24,24,27,0))] flex items-center justify-center p-6">
<div
ref={cardRef}
className={[
"relative w-full max-w-6xl transition-all duration-300 ease-out",
mounted
? "opacity-100 translate-y-0 scale-100"
: "opacity-0 translate-y-1 scale-[0.99]",
].join(" ")}
aria-live="polite"
>
{/* Gradient border wrapper */}
<div className="rounded-2xl p-[1px] bg-gradient-to-b from-zinc-700/60 via-zinc-700/20 to-zinc-800/60 shadow-2xl">
{/* Card */}
<div className="relative rounded-[calc(theme(borderRadius.2xl)-1px)] border border-zinc-800/70 bg-zinc-900/70 supports-[backdrop-filter]:bg-zinc-900/60 backdrop-blur-xl ring-1 ring-white/10">
{/* Accent top line */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent" />
{/* Close button */}
<button
aria-label="Changelog schließen"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 transition"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
{/* Header */}
<header className="px-10 pt-8 pb-6 border-b border-zinc-800/70">
<div className="flex items-center gap-3">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />
</svg>
</div>
<div>
<h1 className="text-[30px] leading-8 font-semibold text-zinc-100 tracking-[-0.01em]">
{title}
</h1>
<p className="text-sm text-zinc-400">
Aktuelle Änderungen und Updates
</p>
</div>
</div>
</header>
{/* Body */}
<div className="relative max-h-[78vh] overflow-y-auto">
<div className="absolute pointer-events-none inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-900/70 to-transparent" />
<div className="absolute pointer-events-none inset-x-0 bottom-0 h-10 bg-gradient-to-t from-zinc-900/80 to-transparent" />
{error && (
<div className="px-10 py-8">
<div className="rounded-lg border border-red-900/40 bg-red-900/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
</div>
)}
{!error && items.length === 0 && (
<div className="px-10 py-16 text-center">
<p className="text-zinc-400">
Kein Changelog im localStorage gefunden (Key: {STORAGE_KEY}
).
</p>
</div>
)}
<ul className="divide-y divide-zinc-800/70">
{items.map((entry, idx) => (
<li
key={`${entry.version ?? entry.date}-${idx}`}
className="px-10 py-8"
>
{/* Kopfzeile je Release */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
{entry.version && (
<span className="inline-flex items-center rounded-md bg-gradient-to-b from-zinc-100 to-zinc-300 text-zinc-900 px-3 py-0.5 text-sm font-semibold shadow-sm">
{entry.version}
</span>
)}
<time
className="text-sm text-zinc-400"
dateTime={entry.date}
>
{new Date(entry.date).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "2-digit",
})}
</time>
</div>
{/* Zweispaltiges Layout: Typ links, Text rechts (mit schöner Leselänge) */}
<dl
role="list"
className="mt-6 grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-[max-content_1fr]"
>
{entry.changes.map((c, i) => (
<div key={i} className="contents">
<dt className="md:w-44 md:justify-end md:text-right">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${
typeStyles[c.type] ??
"bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40"
}`}
>
{c.type}
</span>
</dt>
<dd className="max-w-[74ch] text-[15px] leading-7 text-zinc-200 tracking-[0.005em]">
{Array.isArray(c.text) ? (
<ul className="ml-4 list-disc marker:text-zinc-500/70 space-y-1.5">
{c.text.map((t, k) => (
<li key={k} className="break-words">
{t}
</li>
))}
</ul>
) : (
<p className="break-words">{c.text}</p>
)}
</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</div>
{/* soft bottom glow */}
<div className="pointer-events-none absolute inset-x-12 -bottom-4 h-8 blur-2xl bg-indigo-600/20 rounded-full" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
import {
Badge,
Button,
Flex,
Heading,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
IconButton,
Menu,
Box,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie";
import { useAtom } from "jotai";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useNavigate } from "react-router-dom";
import {
CircleUserRound,
RotateCcwKey,
Code,
LifeBuoy,
LogOut,
CalendarPlus,
MoreVertical,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const Header = () => {
const navigate = useNavigate();
const userData = useUserContext();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle("Passwortänderung fehlgeschlagen");
setMsgDescription("Passwörter stimmen nicht überein");
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/changePassword`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle("Passwortänderung fehlgeschlagen");
setMsgDescription("Bitte überprüfen Sie Ihre Eingaben");
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle("Passwort erfolgreich geändert");
setMsgDescription("Ihr Passwort wurde erfolgreich geändert");
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
const username = userData?.username
? userData.username[0].toUpperCase() + userData.username.slice(1)
: "User";
const logout = () => {
Cookies.remove("token");
setIsLoggedIn(false);
setTriggerLogout(true);
};
return (
<Stack
as="header"
gap={3}
className="mb-6"
position="relative"
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
>
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
<Box
display={{ base: "block", md: "none" }}
position="absolute"
top="50%"
right="0"
transform="translateY(-50%)"
zIndex={2}
>
<Menu.Root>
<Menu.Trigger asChild>
<IconButton
aria-label="Aktionen"
variant="solid"
colorScheme="teal"
size="md"
borderRadius="full"
boxShadow="md"
>
<MoreVertical size={20} />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="create-loan"
onSelect={() => navigate("/", { replace: true })}
children={
<HStack gap={3}>
<CalendarPlus size={16} />
<Text as="span">Ausleihe erstellen</Text>
</HStack>
}
/>
<Menu.Item
value="my-loans"
onSelect={() => navigate("/my-loans", { replace: true })}
children={
<HStack gap={3}>
<CircleUserRound size={16} />
<Text as="span">Meine Ausleihen</Text>
</HStack>
}
/>
<Menu.Item
value="change-password"
onSelect={() => setPwOpen(true)}
children={
<HStack gap={3}>
<RotateCcwKey size={16} />
<Text as="span">Passwort ändern</Text>
</HStack>
}
/>
<Menu.Item
value="help"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<LifeBuoy size={16} />
<Text as="span">Hilfe</Text>
</HStack>
}
/>
<Menu.Item
value="source-code"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<Code size={16} />
<Text as="span">Source Code</Text>
</HStack>
}
/>
<Menu.Separator />
<Menu.Item
value="logout"
onSelect={logout}
children={
<HStack gap={3} color="red.500">
<LogOut size={16} />
<Text as="span">Logout</Text>
</HStack>
}
/>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
</Box>
<Flex
direction={{ base: "column", md: "row" }}
align={{ base: "stretch", md: "center" }}
justify="space-between"
gap={4}
>
{/* Left: Title + user info */}
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
>
Home
</Heading>
</Flex>
<HStack gap={3} align="center" flexWrap="wrap">
<Text fontSize="md" className="text-slate-600 dark:text-slate-400">
Willkommen zurück, {username}!
</Text>
<Badge variant="subtle" px={2} py={1} borderRadius="full">
Rolle: {userData?.role ?? "—"}
</Badge>
</HStack>
</Stack>
{/* Right: Actions */}
{/* Desktop actions */}
<HStack
gap={2}
align="center"
justify="flex-end"
flexWrap="wrap"
display={{ base: "none", md: "flex" }}
>
<Button
colorScheme="teal"
onClick={() => navigate("/", { replace: true })}
>
<HStack gap={2}>
<CalendarPlus size={18} />
<Text as="span">Ausleihe erstellen</Text>
</HStack>
</Button>
<Button onClick={() => navigate("/my-loans", { replace: true })}>
<HStack gap={2}>
<CircleUserRound size={18} />
<Text as="span">Meine Ausleihen</Text>
</HStack>
</Button>
<Button variant="ghost" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">Passwort ändern</Text>
</HStack>
</Button>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
target="_blank"
>
<Button variant="ghost">
<HStack gap={2}>
<LifeBuoy size={18} />
<Text as="span">Hilfe</Text>
</HStack>
</Button>
</a>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
target="_blank"
>
<Button variant="ghost">
<HStack gap={2}>
<Code size={18} />
<Text as="span">Source Code</Text>
</HStack>
</Button>
</a>
<Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}>
<LogOut size={18} />
<Text as="span">Logout</Text>
</HStack>
</Button>
</HStack>
</Flex>
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>Passwort ändern</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Altes Passwort"
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Neues Passwort"
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Neues Passwort wiederholen"
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">Abbrechen</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
Speichern
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Stack>
);
};

View File

@@ -0,0 +1,22 @@
import React from "react";
import { Alert } from "@chakra-ui/react";
type MyAlertProps = {
status: "error" | "success";
title: string;
description: string;
};
const MyAlert: React.FC<MyAlertProps> = ({ title, description, status }) => {
return (
<Alert.Root status={status}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{title}</Alert.Title>
<Alert.Description>{description}</Alert.Description>
</Alert.Content>
</Alert.Root>
);
};
export default MyAlert;

View File

@@ -0,0 +1,108 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
const colorMode = forcedTheme || resolvedTheme
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="9" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

View File

@@ -0,0 +1,159 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import * as React from "react"
import { LuEye, LuEyeOff } from "react-icons/lu"
export interface PasswordVisibilityProps {
/**
* The default visibility state of the password input.
*/
defaultVisible?: boolean
/**
* The controlled visibility state of the password input.
*/
visible?: boolean
/**
* Callback invoked when the visibility state changes.
*/
onVisibleChange?: (visible: boolean) => void
/**
* Custom icons for the visibility toggle button.
*/
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
}
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
)
})
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = React.forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}

View File

@@ -0,0 +1,15 @@
"use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
export function Provider(props: ColorModeProviderProps) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
</ChakraProvider>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "bottom-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

View File

@@ -0,0 +1,46 @@
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
import * as React from "react"
export interface TooltipProps extends ChakraTooltip.RootProps {
showArrow?: boolean
portalled?: boolean
portalRef?: React.RefObject<HTMLElement | null>
content: React.ReactNode
contentProps?: ChakraTooltip.ContentProps
disabled?: boolean
}
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
},
)

70
FrontendV2/src/index.css Normal file
View File

@@ -0,0 +1,70 @@
@import "tailwindcss";
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}

13
FrontendV2/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Provider } from "@/components/ui/provider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider>
<App />
</Provider>
</StrictMode>
);

View File

@@ -0,0 +1,166 @@
import {
Container,
Stack,
Text,
Button,
Input,
Spinner,
VStack,
Table,
} from "@chakra-ui/react";
import { useAtom } from "jotai";
import { getBorrowableItems } from "@/utils/Fetcher";
import { useState } from "react";
import MyAlert from "@/components/myChakra/MyAlert";
import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header";
export interface User {
username: string;
role: number;
}
export const HomePage = () => {
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [isLoadingA, setIsLoadingA] = useState(false);
const [selectedItems, setSelectedItems] = useState<number[]>([]);
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const handleCheckboxChange = (itemId: number) => {
setSelectedItems((prevSelected) =>
prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId]
);
};
return (
<Container maxW="7xl" className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<Stack as="main">
<label htmlFor="startDate">
<Text>Startdatum</Text>
</label>
<Input
id="startDate"
placeholder="Startdatum"
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<Text>Enddatum</Text>
</label>
<Input
id="endDate"
placeholder="Enddatum"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<Button
onClick={async () => {
setIsLoadingA(true);
if (!startDate || !endDate) {
setMsgStatus("error");
setMsgTitle("Fehlende Eingaben");
setMsgDescription("Bitte Start- und Enddatum angeben.");
setIsMsg(true);
setIsLoadingA(false);
return;
}
await getBorrowableItems(startDate, endDate).then((response) => {
setIsLoadingA(false);
if (response && response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || "Fehler");
setMsgDescription(
response.description || "Unbekannter Frontend Fehler"
);
setIsMsg(true);
return;
}
setBorrowableItems(response.data);
setIsMsg(false);
console.log(borrowableItems);
});
}}
>
Verfügbare Gegenstände anzeigen
</Button>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
<Table.Header>
<Table.Row bg="bg.subtle">
<Table.ColumnHeader></Table.ColumnHeader>
<Table.ColumnHeader>Gegenstand</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{borrowableItems.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>
<input
onChange={() => handleCheckboxChange(item.id)}
type="checkbox"
name={item.id}
id={item.id}
/>
</Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate).then((response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || "Fehler");
setMsgDescription(
response.description || "Unbekannter Frontend Fehler"
);
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle("Erfolg");
setMsgDescription("Gegenstände erfolgreich ausgeliehen.");
setIsMsg(true);
})
}
>
Gegenstände ausleihen
</Button>
)}
</Stack>
</Container>
);
};

View File

@@ -0,0 +1,245 @@
import React, { useEffect, useState } from "react";
import {
Spinner,
Text,
VStack,
Table,
Heading,
HStack,
Card,
SimpleGrid,
Button,
} from "@chakra-ui/react";
import { Lock, LockOpen } from "lucide-react";
import MyAlert from "@/components/myChakra/MyAlert";
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return "N/A";
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
};
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Loan = {
id: number;
username: string;
start_date: string;
end_date: string;
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
};
type Device = {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
entry_created_at: string;
};
const Landingpage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [isError, setIsError] = useState(false);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Ausleihen)"
);
}
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) {
setDevices(deviceData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Geräte)"
);
}
} catch (e) {
setError(
"error",
"Fehler beim Laden",
"Die Ausleihen konnten nicht geladen werden."
);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return (
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Heading as="h2" size="md" mb={4}>
Alle Ausleihen
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzername</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>{loan.id}</Table.Cell>
<Table.Cell>{loan.username}</Table.Cell>
<Table.Cell>{formatDateTime(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.end_date)}</Table.Cell>
<Table.Cell>
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: loan.loaned_items_name}
</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
{!isLoading && loans.length === 0 && !isError && (
<Text color="gray.500" mt={2}>
Keine Ausleihen vorhanden.
</Text>
)}
<Heading as="h2" size="md" mb={4}>
Alle Geräte
</Heading>
{/* Responsive Grid mit gleich hohen Karten */}
<SimpleGrid minChildWidth="200px" gap={2} alignItems="stretch">
{devices.map((device) => (
<Card.Root
key={device.id}
size="sm"
bg={device.inSafe ? "green" : "red"}
h="full"
minH="100px"
>
<Card.Header>
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />}
<Heading size="md">{device.item_name}</Heading>
</Card.Header>
<Card.Body color="fg.muted">
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
<Text fontWeight="medium" color="fg.muted">
Legende:
</Text>
<Button
size="sm"
variant="subtle"
colorPalette="green"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<LockOpen size={16} />
<Text>Im Schließfach</Text>
</HStack>
</Button>
<Button
size="sm"
variant="subtle"
colorPalette="red"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<Lock size={16} />
<Text>Nicht im Schließfach</Text>
</HStack>
</Button>
</HStack>
</>
);
};
export default Landingpage;

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import MyAlert from "../components/myChakra/MyAlert";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai";
import Cookies from "js-cookie";
import { Navigate, useNavigate } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const LoginPage = () => {
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const navigate = useNavigate();
useEffect(() => {
if (isLoggedIn) {
navigate("/", { replace: true });
}
}, [isLoggedIn, navigate]);
const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
message: data.message ?? "Login fehlgeschlagen",
description: data.description ?? "",
};
}
Cookies.set("token", data.token);
setIsLoggedIn(true);
return { success: true };
};
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isError, setIsError] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const handleLogin = async () => {
const result = await loginFnc(username, password);
if (!result.success) {
setErrorMsg(result.message);
setErrorDsc(result.description);
setIsError(true);
return;
}
setTriggerLogout(false);
navigate("/", { replace: true });
};
if (isLoggedIn) {
return <Navigate to="/" replace />;
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Login</Card.Title>
<Card.Description>
Bitte unten Ihre Zugangsdaten eingeben.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Benutzername</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>Passwort</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert status="error" title={errorMsg} description={errorDsc} />
)}
<Button type="submit" onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={"Logout erfolgreich!"}
description={"Sie wurden erfolgreich abgemeldet."}
/>
)}
</Card.Footer>
</Card.Root>
</form>
</div>
);
};

View File

@@ -0,0 +1,245 @@
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom";
import MyAlert from "@/components/myChakra/MyAlert";
import {
Container,
VStack,
Spinner,
Text,
Table,
Button,
CloseButton,
Dialog,
Portal,
Code,
} from "@chakra-ui/react";
import { Header } from "@/components/Header";
import { Trash2 } from "lucide-react";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const MyLoansPage = () => {
const navigate = useNavigate();
const [loans, setLoans] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [delLoanCode, setDelLoanCode] = useState<number | null>(null);
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
useEffect(() => {
if (!Cookies.get("token")) {
navigate("/login", { replace: true });
return;
}
const fetchLoans = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (!res.ok) {
setMsgStatus("error");
setMsgTitle("Fehler");
setMsgDescription(
"Beim Laden der Ausleihen ist ein Fehler aufgetreten."
);
setIsMsg(true);
return;
}
const data = await res.json();
setLoans(data);
console.log("Fetched loans:", data);
} catch (e) {
setMsgStatus("error");
setMsgTitle("Fehler");
setMsgDescription("Netzwerkfehler beim Laden der Ausleihen.");
setIsMsg(true);
} finally {
setIsLoading(false);
}
};
fetchLoans();
}, [navigate]);
const deleteLoan = async (loanId: number) => {
try {
const res = await fetch(`${API_BASE}/api/SETdeleteLoan/${loanId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (!res.ok) {
setMsgStatus("error");
setMsgTitle("Fehler");
setMsgDescription(
"Beim Löschen der Ausleihe ist ein Fehler aufgetreten."
);
setIsMsg(true);
return;
}
setLoans((prev) => prev.filter((loan) => loan.id !== loanId));
setMsgStatus("success");
setMsgTitle("Erfolg");
setMsgDescription("Ausleihe erfolgreich gelöscht.");
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle("Fehler");
setMsgDescription("Netzwerkfehler beim Löschen der Ausleihe.");
setIsMsg(true);
}
};
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return iso;
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min}`;
};
return (
<>
<Container maxW="7xl" className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{loans && (
<Table.Root
size="sm"
variant="outline"
style={{ tableLayout: "fixed", width: "100%" }}
>
<Table.ColumnGroup>
{/* Ausleihcode */}
<Table.Column style={{ width: "14%" }} />
{/* Startdatum */}
<Table.Column style={{ width: "14%" }} />
{/* Enddatum */}
<Table.Column style={{ width: "14%" }} />
{/* Geräte (flexibler) */}
<Table.Column style={{ width: "28%" }} />
{/* Ausleihdatum */}
<Table.Column style={{ width: "14%" }} />
{/* Rückgabedatum */}
<Table.Column style={{ width: "14%" }} />
{/* Aktionen */}
<Table.Column style={{ width: "8%" }} />
</Table.ColumnGroup>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Ausleihcode</Table.ColumnHeader>
<Table.ColumnHeader>Startdatum</Table.ColumnHeader>
<Table.ColumnHeader>Enddatum</Table.ColumnHeader>
<Table.ColumnHeader>Geräte</Table.ColumnHeader>
<Table.ColumnHeader>Ausleihdatum</Table.ColumnHeader>
<Table.ColumnHeader>Rückgabedatum</Table.ColumnHeader>
<Table.ColumnHeader>Aktionen</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>
<Text title={loan.loan_code}>
<Code variant="solid">{`${loan.loan_code}`}</Code>
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
<Table.Cell>
<Text title={loan.loaned_items_name}>
{loan.loaned_items_name}
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
<Table.Cell>
<Dialog.Root role="alertdialog">
<Dialog.Trigger asChild>
<Button
onClick={() => setDelLoanCode(loan.loan_code)}
aria-label="Ausleihe löschen"
style={{
display: "inline-flex",
alignItems: "center",
}}
>
<Trash2 />
</Button>
</Dialog.Trigger>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Sicher?</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>
Möchtest du die Ausleihe mit dem{" "}
<strong><Code>{delLoanCode}</Code></strong> Code wirklich
löschen?
<br />
Für den Admin bleibt sie weiterhin sichtbar.
</Text>
</Dialog.Body>
<Dialog.Footer>
<Dialog.ActionTrigger asChild>
<Button variant="outline">Abbrechen</Button>
</Dialog.ActionTrigger>
<Button
colorPalette="red"
onClick={() => deleteLoan(loan.id)}
>
<strong>Löschen</strong>
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</Container>
</>
);
};

View File

@@ -0,0 +1,6 @@
import { atom } from "jotai";
export const testAtom = atom<number>(0);
export const setIsLoggedInAtom = atom<boolean>(false);
export const triggerLogoutAtom = atom<boolean>(false);
export const borrowAbleItemsAtom = atom<any[]>([]);

View File

@@ -0,0 +1,19 @@
import { createContext } from "react";
import { useContext } from "react";
export interface User {
username: string;
role: number;
}
export const UserContext = createContext<User | undefined>(undefined);
export function useUserContext() {
const user = useContext(UserContext);
if (user === undefined) {
throw new Error("useUserContext must be used with a UserContext")
}
return user;
}

View File

@@ -0,0 +1,36 @@
# How to use Atoms
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
You can also name it global state.
## Creating an Atom
to create an atom you have to declare an atom like this:
```ts
import { atom } from 'jotai';
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
```
In this project we declare all atoms in the `states/Atoms.tsx`file. Which you can find above this README file.
## Using an Atom
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
```tsx
import { useAtom } from 'jotai';
import { NAME_OF_YOUR_ATOM } from '@/states/Atoms';
const MyComponent = () => {
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
return (
<div>
<p>Current value: {value}</p>
<button onClick={() => setValue(newValue)}>Update Value</button>
</div>
);
};
```
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.

View File

@@ -0,0 +1,82 @@
import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const getBorrowableItems = async (
startDate: string,
endDate: string
) => {
try {
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ startDate, endDate }),
});
if (!response.ok) {
return {
data: null,
status: "error",
title: "Server error",
description:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
};
}
const data = await response.json();
console.log(data);
return {
data: data,
status: "success",
title: null,
description: null,
};
} catch (error) {
return {
data: null,
status: "error",
title: "Netzwerkfehler",
description:
"Es konnte keine Verbindung zum Server hergestellt werden. Bitte überprüfe deine Internetverbindung.",
};
}
};
export const createLoan = async (
itemIds: number[],
startDate: string,
endDate: string
) => {
const response = await fetch(`${API_BASE}/api/createLoan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
body: JSON.stringify({ items: itemIds, startDate, endDate }),
});
if (!response.ok) {
return {
data: null,
status: "error",
title: "Server error",
description:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
};
}
const data = await response.json();
return {
data: data,
status: "success",
title: null,
description: null,
};
};

View File

@@ -0,0 +1,20 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import Cookies from "js-cookie";
import { useContext } from "react";
import { UserContext } from "@/states/Context";
export const ProtectedRoutes = () => {
const user = useContext(UserContext);
const location = useLocation();
const hasToken = Boolean(Cookies.get("token"));
if (hasToken && !user) {
return null;
}
return user ? (
<Outlet />
) : (
<Navigate to="/login" replace state={{ from: location }} />
);
};

View File

@@ -0,0 +1,11 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
// add other paths if needed
],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
FrontendV2/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
FrontendV2/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8001,
watch: {
usePolling: true,
},
},
});

View File

@@ -1,276 +0,0 @@
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

@@ -1,20 +0,0 @@
[
{
"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"
}
]

24
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
admin/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8003
CMD ["npm", "run", "dev"]

69
admin/README.md Normal file
View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
admin/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/user-star.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6078
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
admin/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/react": "^3.26.0",
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.0",
"react-toastify": "^11.0.5",
"split-lines": "^3.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.5",
"vite-plugin-svgr": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4"
}
}

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-user-star-icon lucide-user-star"><path d="M16.051 12.616a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.866l-1.156-1.153a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z"/><path d="M8 15H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 635 B

1
admin/src/App.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

12
admin/src/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import "./App.css";
import Layout from "./Layout/Layout";
function App() {
return (
<>
<Layout />
</>
);
}
export default App;

View File

@@ -0,0 +1,97 @@
import React from "react";
import { useState } from "react";
import { useEffect } from "react";
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
import Sidebar from "./Sidebar";
import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import { MoveLeft } from "lucide-react";
type DashboardProps = {
onLogout?: () => void;
};
const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
const userName = localStorage.getItem("userName") || "Admin";
const [activeView, setActiveView] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.location.pathname.slice(1);
if (raw) {
setActiveView(decodeURIComponent(raw));
}
}, []);
// Sync URL when activeView changes, without reloading
useEffect(() => {
if (typeof window === "undefined") return;
if (!activeView) return;
const desired = `/${encodeURIComponent(activeView)}`;
if (window.location.pathname !== desired) {
window.history.replaceState(null, "", desired);
}
}, [activeView]);
return (
<Flex h="100vh">
<Sidebar
viewAusleihen={() => setActiveView("Ausleihen")}
viewGegenstaende={() => setActiveView("Gegenstände")}
viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
/>
<Box flex="1" display="flex" flexDirection="column">
<Flex
as="header"
align="center"
justify="space-between"
px={6}
py={4}
borderBottom="1px"
borderColor="gray.200"
bg="gray.900"
>
<Heading size="xl">Dashboard</Heading>
<Flex align="center" gap={6}>
<Text fontSize="sm" color="white">
Willkommen {userName}, im Admin-Dashboard!
</Text>
<Button variant="solid" onClick={onLogout}>
Logout
</Button>
</Flex>
</Flex>
<Box as="main" flex="1" p={6}>
{activeView === "" && (
<Flex
align="center"
gap={3}
p={4}
border="1px dashed"
borderColor="gray.300"
borderRadius="md"
bg="gray.50"
color="gray.700"
fontSize="lg"
fontWeight="semibold"
>
<MoveLeft size={20} />
Bitte wählen Sie eine Ansicht aus.
</Flex>
)}
{activeView === "User" && <UserTable />}
{activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
</Box>
</Box>
</Flex>
);
};
export default Dashboard;

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { useEffect } from "react";
import Dashboard from "./Dashboard";
import Login from "./Login";
import Cookies from "js-cookie";
import Landingpage from "@/components/API/Landingpage";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [showAPI, setShowAPI] = useState(false);
useEffect(() => {
const path = window.location.pathname.replace(/\/+$/, ""); // remove trailing slash
if (path === "/api") {
setShowAPI(true);
console.log("signal");
return;
}
if (Cookies.get("token")) {
const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (response.ok) {
setIsLoggedIn(true);
} else {
Cookies.remove("token");
setIsLoggedIn(false);
window.location.reload();
}
};
verifyToken();
}
}, []);
const handleLogout = () => {
Cookies.remove("token");
window.location.pathname = "/";
setIsLoggedIn(false);
};
if (showAPI) {
return (
<main>
<Landingpage />
</main>
);
}
return (
<main>
{isLoggedIn ? (
<Dashboard onLogout={() => handleLogout()} />
) : (
<Login onSuccess={() => setIsLoggedIn(true)} />
)}
</main>
);
};
export default Layout;

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useState } from "react";
import { loginFunc } from "@/utils/loginUser";
import MyAlert from "../components/myChakra/MyAlert";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isError, setIsError] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const handleLogin = async () => {
const result = await loginFunc(username, password);
if (!result.success) {
setErrorMsg(result.message);
setErrorDsc(result.description);
setIsError(true);
return;
}
onSuccess();
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Login</Card.Title>
<Card.Description>
Bitte unten Ihre Admin Zugangsdaten eingeben.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>username</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert status="error" title={errorMsg} description={errorDsc} />
)}
<Button type="submit" onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
</Card.Root>
</form>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,82 @@
import React from "react";
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
type SidebarProps = {
viewAusleihen: () => void;
viewGegenstaende: () => void;
viewSchliessfaecher: () => void;
viewUser: () => void;
viewAPI: () => void;
};
const Sidebar: React.FC<SidebarProps> = ({
viewAusleihen,
viewGegenstaende,
viewUser,
viewAPI,
}) => {
return (
<Box
as="aside"
w="260px"
minH="100vh"
bg="gray.800"
color="gray.100"
px={6}
py={8}
borderRight="1px solid"
borderColor="gray.700"
>
<Flex direction="column" h="full">
<Heading size="md" mb={8} letterSpacing="wide">
Borrow System
</Heading>
<VStack align="stretch" gap={4} fontSize="sm">
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewUser}
>
Benutzer
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewAusleihen}
>
Ausleihen
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewGegenstaende}
>
Gegenstände
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewAPI}
>
API Keys
</Link>
</VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
<Text>&copy; Made with by Theis Gaedigk</Text>
</Box>
</Flex>
</Box>
);
};
export default Sidebar;

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const testAtom = atom<number>(0);

View File

@@ -0,0 +1,36 @@
# How to use Atoms
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
You can also name it global state.
## Creating an Atom
to create an atom you have to declare an atom like this:
```ts
import { atom } from 'jotai';
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
```
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
## Using an Atom
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
```tsx
import { useAtom } from 'jotai';
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
const MyComponent = () => {
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
return (
<div>
<p>Current value: {value}</p>
<button onClick={() => setValue(newValue)}>Update Value</button>
</div>
);
};
```
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.

View File

@@ -0,0 +1 @@
<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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,208 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
HStack,
IconButton,
Heading,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import MyAlert from "./myChakra/MyAlert";
import { Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { deleteAPKey } from "@/utils/userActions";
import AddAPIKey from "./AddAPIKey";
import { formatDateTime } from "@/utils/userFuncs";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = {
id: number;
apiKey: string;
user: string;
entry_created_at: string;
};
const APIKeyTable: React.FC = () => {
const [items, setItems] = useState<Items[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const [addAPIForm, setAddAPIForm] = useState(false);
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/apiKeys`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
return data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="API Keys neu laden" openDelay={300}>
<IconButton
aria-label="Refresh API Keys"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
<Tooltip content="Neuen API Key hinzufügen" openDelay={300}>
<Button
size="sm"
colorPalette="teal"
variant="solid"
rounded="md"
fontWeight="semibold"
shadow="sm"
_hover={{ shadow: "md", bg: "colorPalette.600" }}
_active={{ bg: "colorPalette.700" }}
onClick={() => {
setAddAPIForm(true);
}}
>
<CirclePlus size={18} style={{ marginRight: 6 }} />
Neuen API Key hinzufügen
</Button>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
Gegenstände
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{addAPIForm && (
<AddAPIKey
onClose={() => {
setAddAPIForm(false);
setReload(!reload);
}}
alert={setError}
/>
)}
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>API Key</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((apiKey) => (
<Table.Row key={apiKey.id}>
<Table.Cell>{apiKey.id}</Table.Cell>
<Table.Cell>{apiKey.apiKey}</Table.Cell>
<Table.Cell>{apiKey.user}</Table.Cell>
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteAPKey(apiKey.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== apiKey.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</>
);
};
export default APIKeyTable;

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { createAPIentry } from "@/utils/userActions";
type AddAPIKeyProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen API Key erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen API Key zu erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>API key</Field.Label>
<Input type="number" id="apiKey" />
</Field.Root>
<Field.Root>
<Field.Label>Benutzer</Field.Label>
<Input id="user" type="text" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const apiKey =
(
document.getElementById("apiKey") as HTMLInputElement
)?.value.trim() || "";
const user =
(
document.getElementById("user") as HTMLInputElement
)?.value.trim() || "";
if (!apiKey || !user) return;
const res = await createAPIentry(apiKey, user);
if (res.success) {
alert(
"success",
"API Key erstellt",
"Der API Key wurde erfolgreich erstellt."
);
onClose();
} else {
alert(
"error",
"Fehler beim Erstellen des API Keys",
res.message ||
"Beim Erstellen des API Keys ist ein Fehler aufgetreten. (frontend bug)"
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</div>
);
};
export default AddAPIKey;

View File

@@ -0,0 +1,86 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { createUser } from "@/utils/userActions";
type AddFormProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen Nutzer erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen Nutzer zu erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Username</Field.Label>
<Input id="username" />
</Field.Root>
<Field.Root>
<Field.Label>Password</Field.Label>
<Input id="password" type="password" />
</Field.Root>
<Field.Root>
<Field.Label>Role</Field.Label>
<Input id="role" type="number" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const username =
(
document.getElementById("username") as HTMLInputElement
)?.value.trim() || "";
const password =
(document.getElementById("password") as HTMLInputElement)
?.value || "";
const role = Number(
(document.getElementById("role") as HTMLInputElement)?.value
);
if (!username || !password || Number.isNaN(role)) return;
const res = await createUser(username, role, password);
if (res.success) {
alert(
"success",
"Nutzer erstellt",
"Der Nutzer wurde erfolgreich erstellt."
);
onClose();
} else {
alert(
"error",
"Fehler beim Erstellen des Nutzers",
"Es gab einen Fehler beim Erstellen des Nutzers. Vielleicht gibt es bereits einen Nutzer mit diesem Benutzernamen."
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</div>
);
};
export default AddForm;

View File

@@ -0,0 +1,86 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { createItem } from "@/utils/userActions";
type AddItemFormProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen Gegenstand erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen Gegenstand zu
erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Gegenstandsname</Field.Label>
<Input id="item_name" placeholder="z.B. Laptop" />
</Field.Root>
<Field.Root>
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
<Input
id="can_borrow_role"
type="number"
placeholder="Zahl (1 - 4)"
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end" gap="2">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const name =
(
document.getElementById("item_name") as HTMLInputElement
)?.value.trim() || "";
const role = Number(
(document.getElementById("can_borrow_role") as HTMLInputElement)
?.value
);
if (!name || Number.isNaN(role)) return;
const res = await createItem(name, role);
if (res.success) {
alert(
"success",
"Gegenstand erstellt",
"Der Gegenstand wurde erfolgreich erstellt."
);
onClose();
} else {
alert(
"error",
"Fehler",
res.message ||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</div>
);
};
export default AddItemForm;

View File

@@ -0,0 +1,122 @@
import React from "react";
import { Button, Card, Field, Input, Stack, Alert } from "@chakra-ui/react";
import { changePW } from "@/utils/userActions";
import { useState } from "react";
type ChangePWformProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
username: string;
};
const ChangePWform: React.FC<ChangePWformProps> = ({
onClose,
alert,
username,
}) => {
const [showSubAlert, setShowSubAlert] = useState(false);
const [subAlertMessage, setSubAlertMessage] = useState("");
const subAlert = (message: string) => {
setSubAlertMessage(message);
setShowSubAlert(true);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Passwort ändern</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um das Passwort zu ändern.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Neues Passwort</Field.Label>
<Input
id="new_password"
type="password"
placeholder="Neues Passwort"
/>
</Field.Root>
<Field.Root>
<Field.Label>Neues Passwort widerholen</Field.Label>
<Input
id="confirm_new_password"
type="password"
placeholder="Wiederholen Sie das neue Passwort"
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer gap="2">
<Stack w="full" gap="3">
<Stack direction="row" justify="flex-end" gap="2">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const newPassword =
(
document.getElementById(
"new_password"
) as HTMLInputElement
)?.value.trim() || "";
const confirmNewPassword =
(
document.getElementById(
"confirm_new_password"
) as HTMLInputElement
)?.value.trim() || "";
if (!newPassword || newPassword !== confirmNewPassword) {
subAlert("Passwörter stimmen nicht überein!");
return;
}
const res = await changePW(newPassword, username);
if (res.success) {
alert(
"success",
"Passwort geändert",
"Das Passwort wurde erfolgreich geändert."
);
onClose();
} else {
alert(
"error",
"Fehler",
"Das Passwort konnte nicht geändert werden."
);
onClose();
}
}}
>
Ändern
</Button>
</Stack>
{showSubAlert && (
<Alert.Root status="error">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{subAlertMessage}</Alert.Title>
</Alert.Content>
</Alert.Root>
)}
</Stack>
</Card.Footer>
</Card.Root>
</div>
);
};
export default ChangePWform;

View File

@@ -0,0 +1,312 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
HStack,
IconButton,
Heading,
Icon,
Input,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import MyAlert from "./myChakra/MyAlert";
import {
Trash2,
RefreshCcwDot,
CirclePlus,
CheckCircle2,
XCircle,
Save,
} from "lucide-react";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import {
deleteItem,
handleEditItems,
changeSafeState,
} from "@/utils/userActions";
import AddItemForm from "./AddItemForm";
import { formatDateTime } from "@/utils/userFuncs";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: boolean;
entry_created_at: string;
};
const ItemTable: React.FC = () => {
const [items, setItems] = useState<Items[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const [addForm, setAddForm] = useState(false);
const handleItemNameChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it))
);
};
const handleCanBorrowRoleChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it))
);
};
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/allItems`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
return data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="Gegenstände neu laden" openDelay={300}>
<IconButton
aria-label="Refresh items"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
<Tooltip content="Neuen Gegenstand hinzufügen" openDelay={300}>
<Button
size="sm"
colorPalette="teal"
variant="solid"
rounded="md"
fontWeight="semibold"
shadow="sm"
_hover={{ shadow: "md", bg: "colorPalette.600" }}
_active={{ bg: "colorPalette.700" }}
onClick={() => {
setAddForm(true);
}}
>
<CirclePlus size={18} style={{ marginRight: 6 }} />
Neuen Gegenstand hinzufügen
</Button>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
Gegenstände
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{addForm && (
<AddItemForm
onClose={() => {
setAddForm(false);
setReload(!reload);
}}
alert={setError}
/>
)}
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Gegenstand</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih Berechtigung</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.inSafe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.inSafe ? "green.300" : "red.300"}
_hover={{
bg: item.inSafe ? "green.50" : "red.50",
borderColor: item.inSafe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.inSafe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.inSafe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</>
);
};
export default ItemTable;

View File

@@ -0,0 +1,208 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
HStack,
IconButton,
Heading,
Code,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import { useState, useEffect } from "react";
import Cookies from "js-cookie";
import MyAlert from "./myChakra/MyAlert";
import { formatDateTime } from "@/utils/userFuncs";
import { Trash2, RefreshCcwDot } from "lucide-react";
import { deleteLoan } from "@/utils/userActions";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const LoanTable: React.FC = () => {
const [items, setItems] = useState<Loan[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
type Loan = {
id: number;
username: string;
loan_code: string;
start_date: string;
end_date: string;
take_date: string;
returned_date: string;
created_at: string;
loaned_items_name: string[];
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/allLoans`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
return data;
} catch (error) {
setError("error", "Failed to fetch loans", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="Ausleihen neu laden" openDelay={300}>
<IconButton
aria-label="Refresh loans"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
Ausleihen
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Besitzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih code</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</>
);
};
export default LoanTable;

View File

@@ -0,0 +1,284 @@
import React from "react";
import { useState, useEffect } from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
Input,
HStack,
IconButton,
Heading,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import { fetchUserData } from "@/utils/fetcher";
import { Save, Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
import { handleDelete, handleEdit } from "@/utils/userActions";
import MyAlert from "./myChakra/MyAlert";
import AddForm from "./AddForm";
import { formatDateTime } from "@/utils/userFuncs";
import ChangePWform from "./ChangePWform";
type User = {
id: number;
username: string;
password: string;
role: string;
entry_created_at: string;
};
const UserTable: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [isError, setIsError] = useState(false);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [reload, setReload] = useState(false);
const [addForm, setAddForm] = useState(false);
const [changePWform, setChangePWform] = useState(false);
const [changeUsr, setChangeUsr] = useState("");
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
const handleInputChange = (userId: number, field: string, value: string) => {
setUsers((prevUsers) =>
prevUsers.map((user) =>
user.id === userId ? { ...user, [field]: value } : user
)
);
};
const handlePasswordChange = (username: string) => {
setChangeUsr(username);
setChangePWform(true);
};
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const data = await fetchUserData();
console.log("user api response", data);
if (Array.isArray(data)) {
setUsers(data);
} else {
setError(
"error",
"Failed to load users",
"Invalid data format received"
);
}
} catch (e) {
console.error("Failed to fetch users", e);
if (e instanceof Error) {
setError(
"error",
"Failed to fetch users",
e.message || "Unknown error"
);
} else {
setError("error", "Failed to fetch users", "Unknown error");
}
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, [reload]);
return (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="Benutzer neu laden" openDelay={300}>
<IconButton
aria-label="Refresh users"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
<Tooltip content="Neuen Nutzer hinzufügen" openDelay={300}>
<Button
size="sm"
colorPalette="teal"
variant="solid"
rounded="md"
fontWeight="semibold"
shadow="sm"
_hover={{ shadow: "md", bg: "colorPalette.600" }}
_active={{ bg: "colorPalette.700" }}
onClick={() => {
setAddForm(true);
}}
>
<CirclePlus size={18} style={{ marginRight: 6 }} />
Neuen Nutzer hinzufügen
</Button>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
Benutzer
</Heading>
{changePWform && (
<ChangePWform
onClose={() => {
setChangePWform(false);
setReload(!reload);
}}
alert={setError}
username={changeUsr}
/>
)}
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{addForm && (
<AddForm
onClose={() => {
setAddForm(false);
setReload(!reload);
}}
alert={setError}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzername</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Passwort ändern</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rolle</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{users.map((user) => (
<Table.Row key={user.id}>
<Table.Cell>{user.id}</Table.Cell>
<Table.Cell>
<Input
onChange={(e) =>
handleInputChange(user.id, "username", e.target.value)
}
value={user.username}
/>
</Table.Cell>
<Table.Cell>
<Button onClick={() => handlePasswordChange(user.username)}>
Passwort ändern
</Button>
</Table.Cell>
<Table.Cell>
<Input
type="number"
onChange={(e) =>
handleInputChange(user.id, "role", e.target.value)
}
value={user.role}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
handleEdit(
user.id,
user.username,
user.role,
).then((response) => {
if (response.success) {
setError(
"success",
"User edited",
"The user has been successfully edited."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
handleDelete(user.id).then((response) => {
if (response.success) {
setUsers(users.filter((u) => u.id !== user.id));
setError(
"success",
"User deleted",
"The user has been successfully deleted."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</>
);
};
export default UserTable;

View File

@@ -0,0 +1,22 @@
import React from "react";
import { Alert } from "@chakra-ui/react";
type MyAlertProps = {
status: "error" | "success";
title: string;
description: string;
};
const MyAlert: React.FC<MyAlertProps> = ({ title, description, status }) => {
return (
<Alert.Root status={status}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{title}</Alert.Title>
<Alert.Description>{description}</Alert.Description>
</Alert.Content>
</Alert.Root>
);
};
export default MyAlert;

View File

@@ -0,0 +1,108 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
const colorMode = forcedTheme || resolvedTheme
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

View File

@@ -0,0 +1,15 @@
"use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
export function Provider(props: ColorModeProviderProps) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
</ChakraProvider>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "bottom-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

View File

@@ -0,0 +1,46 @@
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
import * as React from "react"
export interface TooltipProps extends ChakraTooltip.RootProps {
showArrow?: boolean
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
content: React.ReactNode
contentProps?: ChakraTooltip.ContentProps
disabled?: boolean
}
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
},
)

1
admin/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

13
admin/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Provider } from "@/components/ui/provider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider>
<App />
</Provider>
</StrictMode>
);

View File

@@ -0,0 +1,16 @@
import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const fetchUserData = async () => {
const response = await fetch(`${API_BASE}/api/allUsers`, {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
return data;
};

View File

@@ -0,0 +1,48 @@
import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export type LoginSuccess = { success: true };
export type LoginFailure = {
success: false;
message: string;
description: string;
};
export type LoginResult = LoginSuccess | LoginFailure;
export const loginFunc = async (
username: string,
password: string
): Promise<LoginResult> => {
try {
const response = await fetch(`${API_BASE}/api/loginAdmin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return {
success: false,
message: "Login failed!",
description: "Invalid username or password.",
};
}
// Successful login
const data = await response.json();
Cookies.set("token", data.token);
localStorage.setItem("userName", data.first_name);
return { success: true };
} catch (error) {
console.error("Error logging in:", error);
return {
success: false,
message: "Login failed!",
description: "Server error.",
};
}
};

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,257 @@
import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const handleDelete = async (userId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/deleteUser/${userId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete user");
}
return { success: true };
} catch (error) {
console.error("Error deleting user:", error);
return { success: false };
}
};
export const handleEdit = async (
userId: number,
username: string,
role: string
) => {
try {
const response = await fetch(
`${API_BASE}/api/editUser/${userId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ username, role }),
}
);
if (!response.ok) {
throw new Error("Failed to edit user");
}
return { success: true };
} catch (error) {
console.error("Error editing user:", error);
return { success: false };
}
};
export const createUser = async (
username: string,
role: number,
password: string
) => {
try {
const response = await fetch(`${API_BASE}/api/createUser`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ username, role, password }),
});
if (!response.ok) {
throw new Error("Failed to create user");
}
return { success: true };
} catch (error) {
console.error("Error creating user:", error);
return { success: false };
}
};
export const changePW = async (newPassword: string, username: string) => {
try {
const response = await fetch(`${API_BASE}/api/changePWadmin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ newPassword, username }),
});
if (!response.ok) {
throw new Error("Failed to change password");
}
return { success: true };
} catch (error) {
console.error("Error changing password:", error);
return { success: false };
}
};
export const deleteLoan = async (loanId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/deleteLoan/${loanId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete loan");
}
return { success: true };
} catch (error) {
console.error("Error deleting loan:", error);
return { success: false };
}
};
export const deleteItem = async (itemId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/deleteItem/${itemId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete item");
}
return { success: true };
} catch (error) {
console.error("Error deleting item:", error);
return { success: false };
}
};
export const createItem = async (
item_name: string,
can_borrow_role: number
) => {
try {
const response = await fetch(`${API_BASE}/api/createItem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ item_name, can_borrow_role }),
});
if (!response.ok) {
return {
success: false,
message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.",
};
}
return { success: true };
} catch (error) {
console.error("Error creating item:", error);
return { success: false };
}
};
export const handleEditItems = async (
itemId: number,
item_name: string,
can_borrow_role: string
) => {
try {
const response = await fetch(`${API_BASE}/api/updateItemByID`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ itemId, item_name, can_borrow_role }),
});
if (!response.ok) {
throw new Error("Failed to edit item");
}
return { success: true };
} catch (error) {
console.error("Error editing item:", error);
return { success: false };
}
};
export const changeSafeState = async (itemId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/changeSafeState/${itemId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to change safe state");
}
return { success: true };
} catch (error) {
console.error("Error changing safe state:", error);
return { success: false };
}
};
export const createAPIentry = async (apiKey: string, user: string) => {
try {
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ apiKey, user }),
});
if (!response.ok) {
return {
success: false,
message:
"Fehler beim Erstellen des API Keys. Achten Sie darauf, dass alle Felder ausgefüllt sind und der API Key nicht doppelt vergeben wird.",
};
}
return { success: true };
} catch (error) {
console.error("Error creating API entry:", error);
return { success: false };
}
};
export const deleteAPKey = async (apiKeyId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/deleteAPKey/${apiKeyId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete API key");
}
return { success: true };
} catch (error) {
console.error("Error deleting API key:", error);
return { success: false };
}
};

View File

@@ -0,0 +1,7 @@
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return "N/A";
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
};

1
admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

11
admin/tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
// add other paths if needed
],
theme: {
extend: {},
},
plugins: [],
};

36
admin/tsconfig.app.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Chakra / Pfad Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "6.0"
},
"include": ["src"]
}

7
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
admin/tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
admin/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8003,
watch: {
usePolling: true,
},
},
});

View File

@@ -14,7 +14,8 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
},
"node_modules/accepts": {
@@ -713,6 +714,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
}

View File

@@ -7,11 +7,211 @@ import {
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
onTake,
loginAdmin,
onReturn,
getAllUsers,
deleteUserID,
handleEdit,
createUser,
getAllLoans,
getAllItems,
deleteItemID,
createItem,
changeUserPassword,
changeUserPasswordFRONTEND,
changeInSafeStateV2,
updateItemByID,
getAllApiKeys,
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
SETdeleteLoanFromDatabase,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router();
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
// Nice HTML + text templates for the loan email
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { color-scheme: light; supported-color-schemes: light; }
body { margin:0; padding:0; }
/* Mobile stacking */
@media (max-width:480px) {
.outer { width:100% !important; }
.pad-sm { padding:16px !important; }
.w-label { width:120px !important; }
}
/* Dark-mode override safety */
@media (prefers-color-scheme: dark) {
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
.brand-header { background:${brand} !important; color:#ffffff !important; }
a { color:${brand} !important; }
}
</style>
</head>
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
<!-- Preheader (hidden) -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
Neue Ausleihe erstellt Übersicht der Buchung.
</div>
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
<tr>
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
<tbody>
<tr>
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate
)}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
Übersicht öffnen
</a>
</p>
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
console.log("Message sent:", info.messageId);
})();
console.log("sendMailLoan called");
}
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
// Example endpoint
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
@@ -26,7 +226,6 @@ router.post("/login", async (req, res) => {
});
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);
@@ -63,6 +262,16 @@ router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
}
});
router.delete("/SETdeleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(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) {
@@ -86,6 +295,26 @@ router.post("/borrowableItems", authenticate, async (req, res) => {
}
});
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 || {};
@@ -121,6 +350,15 @@ router.post("/createLoan", authenticate, async (req, res) => {
);
if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at
);
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
@@ -145,4 +383,217 @@ router.post("/createLoan", authenticate, async (req, res) => {
}
});
router.post("/changePassword", authenticate, async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
const username = req.user.username;
const result = await changeUserPasswordFRONTEND(
username,
oldPassword,
newPassword
);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
});
// Admin panel functions
router.post("/loginAdmin", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res
.status(400)
.json({ message: "Username and password are required" });
}
const result = await loginAdmin(username, password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
return res.status(200).json({
message: "Login successful",
first_name: result.data.first_name,
token,
});
}
return res.status(401).json({ message: "Invalid credentials" });
});
router.get("/allUsers", authenticate, async (req, res) => {
const result = await getAllUsers();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch users" });
});
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const result = await deleteUserID(userId);
if (result.success) {
return res.status(200).json({ message: "User deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete user" });
});
router.get("/verifyToken", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
router.post("/editUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const { username, role } = req.body || {};
const result = await handleEdit(userId, username, role);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.post("/createUser", authenticate, async (req, res) => {
const { username, role, password } = req.body || {};
const result = await createUser(username, role, password);
if (result.success) {
return res.status(201).json({ message: "User created successfully" });
}
return res.status(500).json({ message: "Failed to create user" });
});
router.get("/allLoans", authenticate, async (req, res) => {
const result = await getAllLoans();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
router.get("/allItems", authenticate, async (req, res) => {
const result = await getAllItems();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch items" });
});
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
const itemId = req.params.id;
const result = await deleteItemID(itemId);
if (result.success) {
return res.status(200).json({ message: "Item deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete item" });
});
router.post("/createItem", authenticate, async (req, res) => {
const { item_name, can_borrow_role } = req.body || {};
const result = await createItem(item_name, can_borrow_role);
if (result.success) {
return res.status(201).json({ message: "Item created successfully" });
}
return res.status(500).json({ message: "Failed to create item" });
});
router.post("/changePWadmin", authenticate, async (req, res) => {
const newPassword = req.body.newPassword;
if (!newPassword) {
return res.status(400).json({ message: "New password is required" });
}
const result = await changeUserPassword(req.body.username, newPassword);
if (result.success) {
return res.status(200).json({ message: "Password changed successfully" });
}
return res.status(500).json({ message: "Failed to change password" });
});
router.post("/updateItemByID", authenticate, async (req, res) => {
const role = req.body.can_borrow_role;
const itemId = req.body.itemId;
const item_name = req.body.item_name;
const result = await updateItemByID(itemId, item_name, role);
if (result.success) {
return res.status(200).json({ message: "Item updated successfully" });
}
return res.status(500).json({ message: "Failed to update item" });
});
router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
const itemId = req.params.itemId;
const result = await changeInSafeStateV2(itemId);
if (result.success) {
return res
.status(200)
.json({ message: "Item safe state updated successfully" });
}
return res.status(500).json({ message: "Failed to update item safe state" });
});
router.get("/apiKeys", authenticate, async (req, res) => {
const result = await getAllApiKeys();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch API keys" });
});
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
const apiKeyId = req.params.id;
const result = await deleteAPKey(apiKeyId);
if (result.success) {
return res.status(200).json({ message: "API key deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete API key" });
});
router.post("/createAPIentry", authenticate, async (req, res) => {
const apiKey = req.body.apiKey;
const user = req.body.user;
if (!apiKey || !user) {
return res.status(400).json({ message: "API key and user are required" });
}
// Ensure apiKey is a number
const apiKeyNum = Number(apiKey);
if (!Number.isFinite(apiKeyNum)) {
return res.status(400).json({ message: "API key must be a number" });
}
const result = await createAPIentry(apiKeyNum, user);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
if (result.code === "DUPLICATE") {
return res.status(409).json({ message: "API key already exists" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.get("/apiKeys/validate/:key", async (req, res) => {
try {
const rawKey = req.params.key;
const result = await getAllApiKeys();
if (!result.success || !Array.isArray(result.data)) {
return res.status(500).json({ valid: false });
}
const isValid = result.data.some((entry) => {
const val = String(
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
);
return val === String(rawKey);
});
return res.status(200).json({ valid: isValid });
} catch (err) {
console.error("validate api key error:", err);
return res.status(500).json({ valid: false });
}
});
export default router;

View File

@@ -3,32 +3,68 @@ import dotenv from "dotenv";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2,
getAllLoansV2,
getAPIkey,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
async function validateAPIKey(apiKey) {
try {
if (!apiKey) return false;
const result = await getAPIkey();
if (!result?.success || !Array.isArray(result.data)) return false;
return result.data.some((row) => String(row.apiKey) === String(apiKey));
} catch (err) {
console.error("validateAPIKey error:", err);
return false;
}
}
// Add a guard that returns Access Denied instead of hanging
const apiKeyGuard = async (req, res, next) => {
try {
const key = req.params.key;
if (!key) {
return res
.status(401)
.json({ message: "Access denied: missing API key" });
}
const ok = await validateAPIKey(key);
if (!ok) {
return res
.status(401)
.json({ message: "Access denied: invalid API key" });
}
next();
} catch (e) {
console.error("apiKeyGuard error:", e);
res.status(500).json({ message: "Internal server error" });
}
};
// 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) {
router.get("/items/:key", apiKeyGuard, async (req, res) => {
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) {
router.post(
"/controlInSafe/:key/:itemId/:state",
apiKeyGuard,
async (req, res) => {
const itemId = req.params.itemId;
const state = req.params.state;
if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state);
if (result.success) {
@@ -39,40 +75,58 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
} else {
res.status(400).json({ message: "Invalid state value" });
}
}
);
// Route for API to get a loan by its code
router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
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(403).json({ message: "Access denied" });
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) {
// Route for API to set the return date by the loan code
router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
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) {
// Route for API to set the take away date by the loan code
router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
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" });
}
});
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
router.get("/allLoans", async (req, res) => {
const result = await getAllLoansV2();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
// Route for API to get ALL items from the database (only for landingpage)
router.get("/allItems", async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(403).json({ message: "Access denied" });
res.status(500).json({ message: "Failed to fetch items" });
}
});

View File

@@ -12,6 +12,17 @@ CREATE TABLE `users` (
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `admins` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`first_name` varchar(255) NOT NULL,
`last_name` varchar(255) NOT 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,
@@ -23,6 +34,7 @@ CREATE TABLE `loans` (
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
`deleted` bool NOT NULL DEFAULT false,
PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`)
);
@@ -47,6 +59,14 @@ CREATE TABLE `lockers` (
UNIQUE KEY `locker_number` (`locker_number`)
);
CREATE TABLE `apiKeys` (
`id` int NOT NULL AUTO_INCREMENT,
`apiKey` int NOT NULL UNIQUE,
`user` VARCHAR(255) NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1),

View File

@@ -28,10 +28,21 @@ export const getItemsFromDatabaseV2 = async () => {
return { success: false };
};
export const changeInSafeStateV2 = async (itemId, state) => {
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"UPDATE items SET inSafe = ? WHERE id = ?",
[state, itemId]
"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) => {
const [result] = await pool.query(
"UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
@@ -40,22 +51,56 @@ export const changeInSafeStateV2 = async (itemId, state) => {
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
@@ -76,17 +121,15 @@ export const getItemsFromDatabase = async (role) => {
};
export const getLoansFromDatabase = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
const [rows] = await pool.query("SELECT * FROM loans;");
return { success: true, data: rows.length > 0 ? rows : null };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
username,
]);
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username]
);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
@@ -107,6 +150,18 @@ export const deleteLoanFromDatabase = async (loanId) => {
}
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
@@ -140,6 +195,16 @@ export const getBorrowableItemsFromDatabase = async (
return { success: false };
};
export const getLoanInfoWithID = async (loanId) => {
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
loanId,
]);
if (rows.length > 0) {
return { success: true, data: rows[0] };
}
return { success: false };
};
export const createLoanInDatabase = async (
username,
startDate,
@@ -221,7 +286,7 @@ export const createLoanInDatabase = async (
// 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 candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
@@ -282,3 +347,203 @@ export const createLoanInDatabase = async (
conn.release();
}
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const onReturn = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const loginAdmin = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM admins WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, role, entry_created_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteUserID = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const handleEdit = async (userId, username, role) => {
const [result] = await pool.query(
"UPDATE users SET username = ?, role = ? WHERE id = ?",
[username, role, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createUser = async (username, role, password) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
[username, role, password]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemID = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
[item_name, can_borrow_role]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPassword = async (username, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPasswordFRONTEND = async (
username,
oldPassword,
newPassword
) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ? AND password = ?",
[newPassword, username, oldPassword]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ? WHERE id = ?",
[item_name, can_borrow_role, itemId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoansV2 = async () => {
const [rows] = await pool.query(
"SELECT id, username, start_date, end_date, loaned_items_name, returned_date, take_date FROM loans"
);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getAllApiKeys = async () => {
const [rows] = await pool.query("SELECT * FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createAPIentry = async (apiKey, user) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
[apiKey, user]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteAPKey = async (apiKeyId) => {
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
apiKeyId,
]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAPIkey = async () => {
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};

View File

@@ -9,7 +9,6 @@ export async function generateToken(payload) {
.setIssuedAt()
.setExpirationTime("2h") // Token valid for 2 hours
.sign(secret);
console.log("Generated token: ", newToken);
return newToken;
}

View File

@@ -1,13 +1,25 @@
services:
# borrow_system-frontend:
# container_name: borrow_system-frontend
# build: ./frontend
# build: ./FrontendV2
# ports:
# - "8001:8001"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./frontend:/app
# - ./FrontendV2:/app
# - /app/node_modules
# restart: unless-stopped
# admin-frontend:
# container_name: admin-frontend
# build: ./admin
# ports:
# - "8003:8003"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./admin:/app
# - /app/node_modules
# restart: unless-stopped
@@ -34,8 +46,10 @@ services:
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: borrow_system
TZ: Europe/Berlin
volumes:
- mysql-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3309:3306"

View File

@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0",
"@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"primeicons": "^7.0.0",
@@ -1836,9 +1837,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz",
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==",
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
"integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1846,12 +1847,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz",
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==",
"version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz",
"integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.3"
"@tanstack/query-core": "5.85.5"
},
"funding": {
"type": "github",
@@ -3164,6 +3165,35 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jotai": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@babel/core": ">=7.0.0",
"@babel/template": ">=7.0.0",
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"@babel/template": {
"optional": true
},
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -4292,13 +4322,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -4469,17 +4499,17 @@
}
},
"node_modules/vite": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"

View File

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

View File

@@ -1,12 +1,14 @@
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 { AddLoan } from "./components/AddLoan";
import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie";
import { fetchAllData, ALL_ITEMS_UPDATED_EVENT } from "./utils/fetchData";
import {
fetchAllData,
ALL_ITEMS_UPDATED_EVENT,
AUTH_LOGOUT_EVENT,
} from "./utils/fetchData";
import { myToast } from "./utils/toastify";
function App() {
@@ -18,10 +20,17 @@ function App() {
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");
@@ -35,13 +44,7 @@ function App() {
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>
<AddLoan />
</Layout>
) : (
<LoginForm onLogin={() => setIsLoggedIn(true)} />

View File

@@ -0,0 +1,6 @@
import { atom } from "jotai";
// Atoms to store the start and end dates for loans
export const startDate = atom<string | null>(null);
export const endDate = atom<string | null>(null);
export const getBorrowableItemsAtom = atom<string[] | boolean>(false);

View File

@@ -0,0 +1,36 @@
# How to use Atoms
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
You can also name it global state.
## Creating an Atom
to create an atom you have to declare an atom like this:
```ts
import { atom } from 'jotai';
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
```
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
## Using an Atom
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
```tsx
import { useAtom } from 'jotai';
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
const MyComponent = () => {
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
return (
<div>
<p>Current value: {value}</p>
<button onClick={() => setValue(newValue)}>Update Value</button>
</div>
);
};
```
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.

View File

@@ -0,0 +1,69 @@
import { getBorrowableItems } from "../utils/fetchData";
import { useAtom } from "jotai";
import { startDate, endDate } from "../States/Atoms";
import Cookies from "js-cookie";
export const AddLoan = () => {
const [start, setStart] = useAtom(startDate);
const [end, setEnd] = useAtom(endDate);
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) || "";
setStart(start);
setEnd(end);
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>
);
};

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;

Some files were not shown because too many files have changed in this diff Show More