Compare commits
23
Commits
6fb03530df
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2c3d7f54 | ||
|
|
8b89f31178 | ||
|
|
c7933dfa3b | ||
|
|
c1bb628f22 | ||
|
|
941f9105d6 | ||
|
|
0070b0e7a0 | ||
|
|
63d3e2db07 | ||
|
|
9c17b2a495 | ||
|
|
929ec5ef10 | ||
|
|
67c704009e | ||
|
|
831de8b610 | ||
|
|
fd43e84c0c | ||
|
|
1ce8d33b0d | ||
|
|
c48da71cd5 | ||
|
|
8e35e81e8f | ||
|
|
95aae1c050 | ||
|
|
2cc5545ea8 | ||
|
|
f8e29dca10 | ||
|
|
e52fc13da4 | ||
|
|
4291552b6d | ||
|
|
5191871681 | ||
|
|
c61a283127 | ||
|
|
4a3c948386 |
@@ -0,0 +1,14 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/dist-ssr
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.log
|
||||||
|
docs
|
||||||
|
pics
|
||||||
|
icon
|
||||||
|
no-as-a-service
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
DB_PASSWORD=
|
||||||
|
BACKEND_URL=
|
||||||
|
MAIL_HOST=
|
||||||
|
MAIL_PORT=
|
||||||
|
MAIL_USER=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
MAIL_SENDEES_CONTACT=
|
||||||
|
MAIL_SENDEES=
|
||||||
|
NAAS_URL=
|
||||||
|
SECRET_KEY=
|
||||||
+3
-1
@@ -11,6 +11,8 @@ dist/
|
|||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
@@ -119,6 +121,6 @@ ToDo.txt
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# psd files from footage
|
# psd files from footage
|
||||||
footage/*.psd
|
pics/*.psd
|
||||||
|
|
||||||
icon/
|
icon/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
You have created an empty `docker-compose.override.yml` file in the root directory of this repository.
|
||||||
|
|
||||||
|
## Docker Network
|
||||||
|
|
||||||
|
If you want to run the stack in a docker network, you need to change the `BACKEND_HOST` variable in the root `.env` file
|
||||||
|
to `stockhome-backend`. To add a docker network, create a `docker-compose.override.yml` file in the root directory of
|
||||||
|
this repository with the following content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
admin-frontend:
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
|
||||||
|
backend:
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
|
||||||
|
usr-frontend:
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
|
||||||
|
no-as-a-service:
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxynet:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
To start off I recommend to set the `BACKEND_HOST` variable to `localhost` and start the stack without a docker network.
|
||||||
|
After you have started the stack and everything is working, you can add a docker network.
|
||||||
|
|
||||||
|
## Ports
|
||||||
|
|
||||||
|
### Change Exposed Ports
|
||||||
|
|
||||||
|
If you want to change the exposed ports, you can do this in the `docker-compose.yml` file like so:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8004:8004"
|
||||||
|
|
||||||
|
usr-frontend:
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Exposed Ports
|
||||||
|
|
||||||
|
If you want to remove the exposed ports, you can do this in the `docker-compose.override.yml` file like so:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
ports: !override [ ]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports: !override [ ]
|
||||||
|
|
||||||
|
usr-frontend:
|
||||||
|
ports: !override [ ]
|
||||||
|
```
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
|
||||||
COPY --from=builder /app/dist .
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -19,14 +19,18 @@ A small full‑stack system to log in, view available items, reserve them for a
|
|||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- Frontend: [frontend/](frontend)
|
- Frontend: [frontend/](frontend)
|
||||||
- Vite/Tailwind config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
|
- Vite/Tailwind
|
||||||
|
config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
|
||||||
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
|
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
|
||||||
- UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
|
-
|
||||||
- Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
|
UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
|
||||||
|
-
|
||||||
|
Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
|
||||||
- Backend: [backend/](backend)
|
- Backend: [backend/](backend)
|
||||||
- Server: [backend/server.js](backend/server.js)
|
- Server: [backend/server.js](backend/server.js)
|
||||||
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
|
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
|
||||||
- DB + services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
|
- DB +
|
||||||
|
services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
|
||||||
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
|
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
|
||||||
- Docs: [docs/](docs)
|
- Docs: [docs/](docs)
|
||||||
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
|
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
|
||||||
@@ -41,7 +45,8 @@ A small full‑stack system to log in, view available items, reserve them for a
|
|||||||
Key frontend utilities:
|
Key frontend utilities:
|
||||||
|
|
||||||
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
|
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
|
||||||
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected time range.
|
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected
|
||||||
|
time range.
|
||||||
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
|
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
|
||||||
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
|
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
|
||||||
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
|
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
|
||||||
@@ -54,20 +59,18 @@ UI flow (main screens):
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- Scripts: see [frontend/package.json](frontend/package.json) and [backend/package.json](backend/package.json)
|
- The NAAS (no-as-a-service) and the mysql database are running in a docker container
|
||||||
- Frontend: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint`
|
- `frontend`, `backend` and `admin` are both in a vite dev server and can be started by `npm run dev` in the root
|
||||||
- Backend: `npm start`
|
directory.
|
||||||
- Linting: ESLint configured via [frontend/eslint.config.js](frontend/eslint.config.js)
|
|
||||||
- TypeScript configs: [frontend/tsconfig.app.json](frontend/tsconfig.app.json), [frontend/tsconfig.node.json](frontend/tsconfig.node.json)
|
|
||||||
|
|
||||||
## Configuration notes
|
## Configuration notes
|
||||||
|
|
||||||
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
|
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry
|
||||||
|
uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
|
||||||
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
|
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
|
||||||
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Cross‑component updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
|
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Cross‑component
|
||||||
|
updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
|
||||||
|
|
||||||
## API documentation
|
## API documentation
|
||||||
|
|
||||||
Refer to the dedicated API docs:
|
Refer to the dedicated [API-Docs](docs/backend_API_docs/README.md)
|
||||||
|
|
||||||
`docs/backend_API_docs/README.md`
|
|
||||||
|
|||||||
+13
-6
@@ -1,19 +1,26 @@
|
|||||||
FROM node:18 as builder
|
# Build context is the repo root (npm workspaces share one root package-lock.json)
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY frontend/package.json frontend/
|
||||||
|
COPY admin/package.json admin/
|
||||||
|
COPY backend/package.json backend/
|
||||||
|
RUN npm ci -w admin
|
||||||
|
|
||||||
COPY . .
|
ARG VITE_BACKEND_URL
|
||||||
RUN npm run build
|
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
|
||||||
|
|
||||||
|
COPY admin/ admin/
|
||||||
|
RUN npm run build -w admin
|
||||||
|
|
||||||
FROM nginx:alpine AS runner
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/admin/dist .
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY admin/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
+1
-1
@@ -14,7 +14,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /backend/ {
|
location /backend/ {
|
||||||
proxy_pass http://borrow_system-backend_v2:8004/;
|
proxy_pass http://borrow-system_backend:8004/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
|||||||
Generated
-6066
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "v1.3.2 (dev)",
|
"version": "1.3.2-dev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
|||||||
viewAPI={() => setActiveView("API")}
|
viewAPI={() => setActiveView("API")}
|
||||||
viewConfig={() => setActiveView("Server Konfiguration")}
|
viewConfig={() => setActiveView("Server Konfiguration")}
|
||||||
/>
|
/>
|
||||||
<Box flex="1" display="flex" flexDirection="column">
|
<Box flex="1" display="flex" flexDirection="column" minH={0}>
|
||||||
<Flex
|
<Flex
|
||||||
as="header"
|
as="header"
|
||||||
align="center"
|
align="center"
|
||||||
@@ -68,7 +68,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Box as="main" flex="1" p={6}>
|
<Box as="main" flex="1" p={6} minH={0} overflow="hidden">
|
||||||
{activeView === "" && (
|
{activeView === "" && (
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -57,32 +57,32 @@ const ItemTable: React.FC = () => {
|
|||||||
|
|
||||||
const handleItemNameChange = (id: number, value: string) => {
|
const handleItemNameChange = (id: number, value: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it))
|
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCanBorrowRoleChange = (id: number, value: string) => {
|
const handleCanBorrowRoleChange = (id: number, value: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it))
|
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLockerNumberChange = (id: number, value: string) => {
|
const handleLockerNumberChange = (id: number, value: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
|
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDoorKeyChange = (id: number, value: string) => {
|
const handleDoorKeyChange = (id: number, value: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
|
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setError = (
|
const setError = (
|
||||||
status: "error" | "success",
|
status: "error" | "success",
|
||||||
message: string,
|
message: string,
|
||||||
description: string
|
description: string,
|
||||||
) => {
|
) => {
|
||||||
setIsError(false);
|
setIsError(false);
|
||||||
setErrorStatus(status);
|
setErrorStatus(status);
|
||||||
@@ -102,7 +102,7 @@ const ItemTable: React.FC = () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
@@ -193,11 +193,50 @@ const ItemTable: React.FC = () => {
|
|||||||
|
|
||||||
{/* make table fill available width, like UserTable */}
|
{/* make table fill available width, like UserTable */}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
|
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
|
||||||
<Table.Root
|
<Table.Root
|
||||||
size="sm"
|
size="sm"
|
||||||
striped
|
striped
|
||||||
w="100%"
|
stickyHeader
|
||||||
style={{ tableLayout: "auto" }} // Spalten nach Content
|
css={{
|
||||||
|
"& [data-sticky]": {
|
||||||
|
position: "sticky",
|
||||||
|
zIndex: 1,
|
||||||
|
bg: "bg",
|
||||||
|
|
||||||
|
_after: {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
top: "0",
|
||||||
|
bottom: "-1px",
|
||||||
|
width: "32px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [data-sticky=end]": {
|
||||||
|
_after: {
|
||||||
|
insetInlineEnd: "0",
|
||||||
|
translate: "100% 0",
|
||||||
|
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [data-sticky=start]": {
|
||||||
|
_after: {
|
||||||
|
insetInlineStart: "0",
|
||||||
|
translate: "-100% 0",
|
||||||
|
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& thead tr": {
|
||||||
|
shadow: "0 1px 0 0 {colors.border}",
|
||||||
|
"&:has(th[data-sticky])": {
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
@@ -315,8 +354,12 @@ const ItemTable: React.FC = () => {
|
|||||||
value={item.door_key}
|
value={item.door_key}
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
|
<Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
|
{formatDateTime(item.entry_created_at)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{formatDateTime(item.entry_updated_at)}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
|
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
|
||||||
<Table.Cell>{item.currently_borrowing}</Table.Cell>
|
<Table.Cell>{item.currently_borrowing}</Table.Cell>
|
||||||
<Table.Cell whiteSpace="nowrap">
|
<Table.Cell whiteSpace="nowrap">
|
||||||
@@ -327,7 +370,7 @@ const ItemTable: React.FC = () => {
|
|||||||
item.item_name,
|
item.item_name,
|
||||||
item.safe_nr,
|
item.safe_nr,
|
||||||
item.door_key,
|
item.door_key,
|
||||||
item.can_borrow_role
|
item.can_borrow_role,
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setError(
|
setError(
|
||||||
@@ -338,7 +381,7 @@ const ItemTable: React.FC = () => {
|
|||||||
item.item_name +
|
item.item_name +
|
||||||
'" mit ID ' +
|
'" mit ID ' +
|
||||||
item.id +
|
item.id +
|
||||||
" bearbeitet."
|
" bearbeitet.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -356,7 +399,7 @@ const ItemTable: React.FC = () => {
|
|||||||
setError(
|
setError(
|
||||||
"success",
|
"success",
|
||||||
"Gegenstand gelöscht",
|
"Gegenstand gelöscht",
|
||||||
"Der Gegenstand wurde erfolgreich gelöscht."
|
"Der Gegenstand wurde erfolgreich gelöscht.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -372,6 +415,7 @@ const ItemTable: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
</Table.ScrollArea>
|
||||||
)}
|
)}
|
||||||
<Text>* LaP = Letzte ausleihende Person</Text>
|
<Text>* LaP = Letzte ausleihende Person</Text>
|
||||||
<Text>** Dav = Derzeit ausgeliehen von</Text>
|
<Text>** Dav = Derzeit ausgeliehen von</Text>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
Table,
|
Table,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
@@ -31,7 +32,7 @@ const LoanTable: React.FC = () => {
|
|||||||
const setError = (
|
const setError = (
|
||||||
status: "error" | "success",
|
status: "error" | "success",
|
||||||
message: string,
|
message: string,
|
||||||
description: string
|
description: string,
|
||||||
) => {
|
) => {
|
||||||
setIsError(false);
|
setIsError(false);
|
||||||
setErrorStatus(status);
|
setErrorStatus(status);
|
||||||
@@ -65,7 +66,7 @@ const LoanTable: React.FC = () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
@@ -83,7 +84,7 @@ const LoanTable: React.FC = () => {
|
|||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box h="full" display="flex" flexDirection="column" minH={0}>
|
||||||
{/* Action toolbar */}
|
{/* Action toolbar */}
|
||||||
<HStack
|
<HStack
|
||||||
mb={4}
|
mb={4}
|
||||||
@@ -131,7 +132,51 @@ const LoanTable: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Table.Root size="sm" striped>
|
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
|
||||||
|
<Table.Root
|
||||||
|
size="sm"
|
||||||
|
striped
|
||||||
|
stickyHeader
|
||||||
|
css={{
|
||||||
|
"& [data-sticky]": {
|
||||||
|
position: "sticky",
|
||||||
|
zIndex: 1,
|
||||||
|
bg: "bg",
|
||||||
|
|
||||||
|
_after: {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
top: "0",
|
||||||
|
bottom: "-1px",
|
||||||
|
width: "32px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [data-sticky=end]": {
|
||||||
|
_after: {
|
||||||
|
insetInlineEnd: "0",
|
||||||
|
translate: "100% 0",
|
||||||
|
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& [data-sticky=start]": {
|
||||||
|
_after: {
|
||||||
|
insetInlineStart: "0",
|
||||||
|
translate: "-100% 0",
|
||||||
|
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& thead tr": {
|
||||||
|
shadow: "0 1px 0 0 {colors.border}",
|
||||||
|
"&:has(th[data-sticky])": {
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
@@ -141,7 +186,7 @@ const LoanTable: React.FC = () => {
|
|||||||
<strong>Besitzer</strong>
|
<strong>Besitzer</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Ausleih code</strong>
|
<strong>Ausleihcode</strong>
|
||||||
</Table.ColumnHeader>
|
</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>
|
<Table.ColumnHeader>
|
||||||
<strong>Startdatum</strong>
|
<strong>Startdatum</strong>
|
||||||
@@ -193,7 +238,7 @@ const LoanTable: React.FC = () => {
|
|||||||
setError(
|
setError(
|
||||||
"success",
|
"success",
|
||||||
"Loan deleted",
|
"Loan deleted",
|
||||||
"The loan has been successfully deleted."
|
"The loan has been successfully deleted.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -209,8 +254,9 @@ const LoanTable: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
</Table.ScrollArea>
|
||||||
)}
|
)}
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Build context is the repo root (npm workspaces share one root package-lock.json)
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY frontend/package.json frontend/
|
||||||
|
COPY admin/package.json admin/
|
||||||
|
COPY backend/package.json backend/
|
||||||
|
RUN npm ci -w backend --omit=dev
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
|
||||||
|
# Generate info.json from the workspace package.json versions, without the "-dev" suffix
|
||||||
|
RUN node -e ' \
|
||||||
|
const fs = require("fs"); \
|
||||||
|
const v = (p) => "v" + JSON.parse(fs.readFileSync(p + "/package.json")).version.replace(/-dev$/, ""); \
|
||||||
|
fs.writeFileSync("backend/info.json", JSON.stringify({ \
|
||||||
|
"backend-info": { version: v("backend") }, \
|
||||||
|
"frontend-info": { version: v("frontend") }, \
|
||||||
|
"admin-panel-info": { version: v("admin") } \
|
||||||
|
}, null, 4)); \
|
||||||
|
'
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
EXPOSE 8004
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
"version": "v2.2 (dev)"
|
"version": "v2.2 (dev)"
|
||||||
},
|
},
|
||||||
"admin-panel-info": {
|
"admin-panel-info": {
|
||||||
"version": "v1.3.2 (dev)"
|
"version": "v1.4 (dev)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "backendv2",
|
"name": "backend",
|
||||||
"version": "v2.1.1 (dev)",
|
"version": "2.1.1-dev",
|
||||||
|
"private": true,
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"dev": "node --watch server.js",
|
||||||
"start": "node server.js"
|
"start": "NODE_ENV=production node server.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -18,6 +19,6 @@
|
|||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.14.3",
|
||||||
"nodemailer": "^7.0.6"
|
"nodemailer": "^10.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-2
@@ -12,12 +12,15 @@ const pool = mysql
|
|||||||
.promise();
|
.promise();
|
||||||
|
|
||||||
export const getAllLoans = async () => {
|
export const getAllLoans = async () => {
|
||||||
const [rows] = await pool.query("SELECT * FROM loans");
|
const [rows] = await pool.query("SELECT * FROM loans WHERE deleted_admin = 0;");
|
||||||
return { success: true, data: rows };
|
return { success: true, data: rows };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteLoanById = async (loanId) => {
|
export const deleteLoanById = async (loanId) => {
|
||||||
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
|
const [result] = await pool.query(
|
||||||
|
"UPDATE loans SET deleted = true, deleted_admin = true WHERE id = ?",
|
||||||
|
[loanId],
|
||||||
|
);
|
||||||
if (result.affectedRows > 0) return { success: true };
|
if (result.affectedRows > 0) return { success: true };
|
||||||
return { success: false };
|
return { success: false };
|
||||||
};
|
};
|
||||||
@@ -119,14 +119,6 @@ export const setTakeDateV2 = async (loanCode) => {
|
|||||||
return { message: "Failed to set take date", success: false };
|
return { message: "Failed to set take date", success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllLoansV2 = async () => {
|
|
||||||
const [result] = await pool.query("SELECT * FROM loans;");
|
|
||||||
if (result.length > 0) {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const openDoor = async (doorKey) => {
|
export const openDoor = async (doorKey) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
||||||
+17
-22
@@ -63,11 +63,9 @@ export const createLoanInDatabase = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemNames = itemIds
|
const itemNames = itemIds.map(
|
||||||
.map(
|
(id) => itemsRows.find((r) => Number(r.id) === Number(id)).item_name,
|
||||||
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name,
|
);
|
||||||
)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
// Build lockers array (unique, only 2-digit numbers from safe_nr)
|
// Build lockers array (unique, only 2-digit numbers from safe_nr)
|
||||||
const lockers = [
|
const lockers = [
|
||||||
@@ -109,27 +107,24 @@ export const createLoanInDatabase = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique loan_code (retry a few times)
|
let index = false;
|
||||||
let loanCode = null;
|
let candidate;
|
||||||
for (let i = 0; i < 6; i++) {
|
let loanCode;
|
||||||
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
|
|
||||||
const [exists] = await conn.query(
|
// Generates 6-digit loan code
|
||||||
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
|
do {
|
||||||
|
candidate = Math.floor(100000 + Math.random() * 899999);
|
||||||
|
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
"SELECT 1 FROM loans where loan_code = ? LIMIT 1",
|
||||||
[candidate],
|
[candidate],
|
||||||
);
|
);
|
||||||
if (exists.length === 0) {
|
|
||||||
|
if (rows.length == 0) {
|
||||||
|
index = true;
|
||||||
loanCode = candidate;
|
loanCode = candidate;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!loanCode) {
|
|
||||||
await conn.rollback();
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
code: "SERVER_ERROR",
|
|
||||||
message: "Failed to generate unique loan code",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
} while (index === false);
|
||||||
|
|
||||||
// Insert loan (now includes lockers)
|
// Insert loan (now includes lockers)
|
||||||
const [insertRes] = await conn.query(
|
const [insertRes] = await conn.query(
|
||||||
+11
-3
@@ -14,7 +14,7 @@ const pool = mysql
|
|||||||
export const loginFunc = async (username, password) => {
|
export const loginFunc = async (username, password) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
"SELECT * FROM users WHERE username = ? AND password = ?",
|
||||||
[username, password]
|
[username, password],
|
||||||
);
|
);
|
||||||
if (result.length > 0) return { success: true, data: result[0] };
|
if (result.length > 0) return { success: true, data: result[0] };
|
||||||
return { success: false };
|
return { success: false };
|
||||||
@@ -40,7 +40,7 @@ export const changePassword = async (username, oldPassword, newPassword) => {
|
|||||||
// get user current password
|
// get user current password
|
||||||
const [user] = await pool.query(
|
const [user] = await pool.query(
|
||||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
"SELECT * FROM users WHERE username = ? AND password = ?",
|
||||||
[username, oldPassword]
|
[username, oldPassword],
|
||||||
);
|
);
|
||||||
if (user.length === 0) return { success: false };
|
if (user.length === 0) return { success: false };
|
||||||
|
|
||||||
@@ -48,8 +48,16 @@ export const changePassword = async (username, oldPassword, newPassword) => {
|
|||||||
|
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"UPDATE users SET password = ? WHERE username = ?",
|
"UPDATE users SET password = ? WHERE username = ?",
|
||||||
[newPassword, username]
|
[newPassword, username],
|
||||||
);
|
);
|
||||||
if (result.affectedRows > 0) return { success: true };
|
if (result.affectedRows > 0) return { success: true };
|
||||||
return { success: false };
|
return { success: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDeactivatedServices = async () => {
|
||||||
|
const [rows] = await pool.query("SELECT function_name FROM functions WHERE active = 0;");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
return { success: true, data: rows };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
};
|
||||||
@@ -4,9 +4,14 @@ import {
|
|||||||
checkIfServiceIsActive,
|
checkIfServiceIsActive,
|
||||||
checkIfServiceIsActive2,
|
checkIfServiceIsActive2,
|
||||||
} from "../../services/functions.js";
|
} from "../../services/functions.js";
|
||||||
const router = express.Router();
|
|
||||||
|
// mailer imports
|
||||||
|
import { sendMail } from "../../services/mailer/send.js";
|
||||||
|
import { loanMail } from "../../services/mailer/templates/loan_created.js";
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
const loan_service = "Loan Service";
|
const loan_service = "Loan Service";
|
||||||
const loan_mailer_service = "Loan Mailer";
|
const loan_mailer_service = "Loan Mailer";
|
||||||
@@ -23,7 +28,6 @@ import {
|
|||||||
setReturnDate,
|
setReturnDate,
|
||||||
setTakeDate,
|
setTakeDate,
|
||||||
} from "./database/loansMgmt.database.js";
|
} from "./database/loansMgmt.database.js";
|
||||||
import { sendMailLoan } from "./services/mailer.js";
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/createLoan",
|
"/createLoan",
|
||||||
@@ -63,19 +67,24 @@ router.post(
|
|||||||
note,
|
note,
|
||||||
itemIds,
|
itemIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (await checkIfServiceIsActive2(loan_mailer_service)) {
|
if (await checkIfServiceIsActive2(loan_mailer_service)) {
|
||||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||||
console.log(mailInfo);
|
console.log(mailInfo);
|
||||||
sendMailLoan(
|
const { html, text } = loanMail(
|
||||||
mailInfo.data.username,
|
req.user.first_name + " " + req.user.last_name,
|
||||||
mailInfo.data.loaned_items_name,
|
mailInfo.data.loaned_items_name,
|
||||||
mailInfo.data.start_date,
|
mailInfo.data.start_date,
|
||||||
mailInfo.data.end_date,
|
mailInfo.data.end_date,
|
||||||
mailInfo.data.created_at,
|
mailInfo.data.created_at,
|
||||||
mailInfo.data.note,
|
mailInfo.data.note,
|
||||||
);
|
);
|
||||||
|
await sendMail({
|
||||||
|
to: process.env.MAIL_SENDEES,
|
||||||
|
subject: "Neue Ausleihe erstellt!",
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
@@ -9,8 +9,15 @@ const user_frontend_service = "User Frontend";
|
|||||||
const contact_form_service = "Contact Form Service";
|
const contact_form_service = "Contact Form Service";
|
||||||
|
|
||||||
// database funcs import
|
// database funcs import
|
||||||
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
|
import {
|
||||||
import { sendMail } from "./services/mailer_v2.js";
|
loginFunc,
|
||||||
|
changePassword,
|
||||||
|
getDeactivatedServices,
|
||||||
|
} from "./database/userMgmt.database.js";
|
||||||
|
|
||||||
|
// mailer imports
|
||||||
|
import { sendMail } from "../../services/mailer/send.js";
|
||||||
|
import { contactMail } from "../../services/mailer/templates/contact.js";
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/login",
|
"/login",
|
||||||
@@ -54,13 +61,39 @@ router.post(
|
|||||||
checkIfServiceIsActive(contact_form_service),
|
checkIfServiceIsActive(contact_form_service),
|
||||||
authenticate,
|
authenticate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const message = req.body.message;
|
try {
|
||||||
const username = req.user.username;
|
const message = req.body?.message;
|
||||||
|
const username = req.user?.first_name + " " + req.user?.last_name;
|
||||||
|
|
||||||
sendMail(username, message);
|
if (!username || !message) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Username and message are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { html, text } = contactMail({ username, message });
|
||||||
|
await sendMail({
|
||||||
|
to: process.env.MAIL_SENDEES_CONTACT,
|
||||||
|
subject: "Neue Nachricht!",
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({ message: "Contact message sent successfully" });
|
res.status(200).json({ message: "Contact message sent successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send contact mail:", error);
|
||||||
|
res.status(500).json({ message: "Failed to send contact message" });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get("/deactivated-services", authenticate, async (req, res) => {
|
||||||
|
const result = await getDeactivatedServices();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to fetch deactivated services" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -27,6 +27,7 @@ CREATE TABLE loans (
|
|||||||
loaned_items_id json NOT NULL DEFAULT ('[]'),
|
loaned_items_id json NOT NULL DEFAULT ('[]'),
|
||||||
loaned_items_name json NOT NULL DEFAULT ('[]'),
|
loaned_items_name json NOT NULL DEFAULT ('[]'),
|
||||||
deleted bool NOT NULL DEFAULT false,
|
deleted bool NOT NULL DEFAULT false,
|
||||||
|
deleted_admin bool NOT NULL DEFAULT false,
|
||||||
note varchar(500) DEFAULT NULL,
|
note varchar(500) DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
CHECK (loan_code REGEXP '^[0-9]{6}$')
|
CHECK (loan_code REGEXP '^[0-9]{6}$')
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import info from "./info.json" assert { type: "json" };
|
import info from "./info.json" with { type: "json" };
|
||||||
import { authenticate } from "./services/authentication.js";
|
import { authenticate } from "./services/authentication.js";
|
||||||
import { rateLimit } from "express-rate-limit";
|
import { rateLimit } from "express-rate-limit";
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { transporter } from "./transporter.js";
|
||||||
|
|
||||||
|
export async function sendMail({ to, subject, text, html }) {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
console.log("Mail sent:", info.messageId);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export function contactMail({ username, message }) {
|
||||||
|
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>Neue Nachricht</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Kontaktanfrage im Ausleihsystem.</div>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:${brand};padding:32px 30px 28px;">
|
||||||
|
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Nachricht</h1>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Eine neue Kontaktanfrage ist eingegangen.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Accent line -->
|
||||||
|
<tr>
|
||||||
|
<td style="height:3px;line-height:3px;font-size:1px;background:${brand};"> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:28px 30px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;border-bottom:1px solid #f3f4f6;">
|
||||||
|
Benutzername
|
||||||
|
</td>
|
||||||
|
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;border-bottom:1px solid #f3f4f6;">
|
||||||
|
${username || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;">
|
||||||
|
Nachricht
|
||||||
|
</td>
|
||||||
|
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;white-space:pre-line;">
|
||||||
|
${message || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
|
||||||
|
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
|
||||||
|
Bitte antworten Sie nicht auf diese Nachricht.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const text = `Neue Kontaktanfrage\n\nBenutzername: ${username}\nNachricht:\n${message}`;
|
||||||
|
|
||||||
|
return { html, text };
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (value == null) return "N/A";
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return "N/A";
|
||||||
|
return (
|
||||||
|
d.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}) + " Uhr"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loanMail(user, items, startDate, endDate, createdDate, note) {
|
||||||
|
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||||
|
|
||||||
|
const itemsHtml =
|
||||||
|
Array.isArray(items) && items.length
|
||||||
|
? items
|
||||||
|
.map(
|
||||||
|
(i) =>
|
||||||
|
`<span style="display:inline-block;background:#f0f9ff;color:#0369a1;padding:4px 12px;margin:2px 4px 2px 0;border-radius:20px;font-size:13px;font-weight:500;">${i}</span>`,
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
: '<span style="color:#9ca3af;">Keine Gegenstände</span>';
|
||||||
|
|
||||||
|
const row = (label, value, isLast = false) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
|
||||||
|
${label}
|
||||||
|
</td>
|
||||||
|
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
|
||||||
|
${value}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>Neue Ausleihe</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Ausleihe erstellt – Details zur Buchung.</div>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 16px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:${brand};padding:32px 30px 28px;">
|
||||||
|
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Ausleihe</h1>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Es wurde soeben eine neue Ausleihe im System angelegt.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Accent line -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:0;height:3px;line-height:3px;font-size:1px;background:${brand};"> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:28px 30px 10px;">
|
||||||
|
<p style="margin:0 0 14px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1.5px;text-transform:uppercase;">Details zur Ausleihe</p>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
|
||||||
|
${row("Benutzer", user || "N/A")}
|
||||||
|
${row("Gegenstände", itemsHtml)}
|
||||||
|
${row("Startdatum", formatDateTime(startDate))}
|
||||||
|
${row("Enddatum", formatDateTime(endDate))}
|
||||||
|
${row("Erstellt am", formatDateTime(createdDate))}
|
||||||
|
${row("Notiz", note || '<span style="color:#9ca3af;">Keine Notiz</span>', true)}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Button -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:20px 30px 32px;" align="center">
|
||||||
|
<a href="https://admin.insta.the1s.de/api" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:inline-block;background:${brand};color:#ffffff;text-decoration:none;padding:13px 28px;border-radius:8px;font-weight:600;font-size:15px;">
|
||||||
|
Ausleihe ansehen →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
|
||||||
|
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
|
||||||
|
Bitte antworten Sie nicht auf diese Nachricht.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const itemsText = Array.isArray(items) ? items.join(", ") : "N/A";
|
||||||
|
const text = [
|
||||||
|
"Neue Ausleihe erstellt",
|
||||||
|
"-".repeat(30),
|
||||||
|
`Benutzer: ${user || "N/A"}`,
|
||||||
|
`Gegenstaende: ${itemsText}`,
|
||||||
|
`Start: ${formatDateTime(startDate)}`,
|
||||||
|
`Ende: ${formatDateTime(endDate)}`,
|
||||||
|
`Erstellt: ${formatDateTime(createdDate)}`,
|
||||||
|
`Notiz: ${note || "Keine Notiz"}`,
|
||||||
|
"",
|
||||||
|
"-> https://admin.insta.the1s.de/api",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { html, text };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /backend
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 8004
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
Generated
-1133
File diff suppressed because it is too large
Load Diff
@@ -1,215 +0,0 @@
|
|||||||
import nodemailer from "nodemailer";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildLoanEmail({
|
|
||||||
user,
|
|
||||||
items,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
createdDate,
|
|
||||||
note,
|
|
||||||
}) {
|
|
||||||
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>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
|
|
||||||
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
|
|
||||||
note || "Keine Notiz"
|
|
||||||
}</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,
|
|
||||||
note,
|
|
||||||
}) {
|
|
||||||
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)}`,
|
|
||||||
`Notiz: ${note || "Keine Notiz"}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendMailLoan(
|
|
||||||
user,
|
|
||||||
items,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
createdDate,
|
|
||||||
note,
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
note,
|
|
||||||
}),
|
|
||||||
html: buildLoanEmail({
|
|
||||||
user,
|
|
||||||
items,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
createdDate,
|
|
||||||
note,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Loan message sent:", info.messageId);
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import nodemailer from "nodemailer";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
export function sendMail(username, message) {
|
|
||||||
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 mailText = `Neue Kontaktanfrage im Ausleihsystem.\n\nBenutzername: ${username}\n\nNachricht:\n${message}`;
|
|
||||||
|
|
||||||
const mailHtml = `<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Neue Nachricht im Ausleihsystem</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
|
|
||||||
<h2>Neue Nachricht im Ausleihsystem</h2>
|
|
||||||
<p><strong>Benutzername:</strong> ${username}</p>
|
|
||||||
<p><strong>Nachricht:</strong></p>
|
|
||||||
<p style="white-space: pre-line;">${message}</p>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
|
||||||
to: process.env.MAIL_SENDEES_CONTACT,
|
|
||||||
subject: "Sie haben eine neue Nachricht!",
|
|
||||||
text: mailText,
|
|
||||||
html: mailHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Contact message sent: %s", info.messageId);
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Changelog for upcoming version: v2.2
|
|
||||||
|
|
||||||
This update provides some new features for the design. It also contains some improvements and I have also fixed some bugs.
|
|
||||||
|
|
||||||
## New features
|
|
||||||
|
|
||||||
- The overview page now has the note column and is overall better organised
|
|
||||||
- I also addded the regular header to the page
|
|
||||||
- I have added three animations to the Borrow System
|
|
||||||
- I have added a new icon for the frontend, which is now also used in the header and the favicon. It is a dark version of the old icon, which fits better to the overall design. I have made it with Icon Composer. The old icon is still used for the admin panel, which has a light design. (Maybe I will change the admin panel design in the future...)
|
|
||||||
- When you go to your user card (over the user icon in the header) you have a new button "Click me". If you click it, you will get an message... _I am just saying: I have implemented the no-as-a-service code in to my Backend._
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- I have the error logging for the API route wehre you can take loans improved.
|
|
||||||
- If you try to delete a loan that has not been returned yet, you will get an 507 error code.
|
|
||||||
|
|
||||||
## Fixed bugs
|
|
||||||
|
|
||||||
- Fixed bug: #13
|
|
||||||
- Fixed bug for messaging when server has an error
|
|
||||||
- Fixed footer height
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New version numbers
|
|
||||||
|
|
||||||
**Backend:** v2.2
|
|
||||||
|
|
||||||
**Frontend:** v2.2
|
|
||||||
|
|
||||||
**Admin panel:** v1.3.2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
-[Theis](https://portfolio-theis.de)
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
# usr-frontend_v2:
|
||||||
|
# container_name: borrow_system-usr-frontend
|
||||||
|
# build: ./FrontendV2
|
||||||
|
# ports:
|
||||||
|
# - "8001:80"
|
||||||
|
# restart: always
|
||||||
|
|
||||||
|
# admin-frontend:
|
||||||
|
# container_name: borrow_system-admin-frontend
|
||||||
|
# build: ./admin
|
||||||
|
# ports:
|
||||||
|
# - "8003:80"
|
||||||
|
# restart: always
|
||||||
|
|
||||||
|
# backend_v2:
|
||||||
|
# container_name: borrow_system-backend_v2
|
||||||
|
# build: ./backend
|
||||||
|
# ports:
|
||||||
|
# - "8004:8004"
|
||||||
|
# environment:
|
||||||
|
# NODE_ENV: production
|
||||||
|
# DB_HOST: mysql_v2
|
||||||
|
# DB_USER: root
|
||||||
|
# DB_PASSWORD: ${DB_PASSWORD_V2}
|
||||||
|
# DB_NAME: borrow_system_new
|
||||||
|
# depends_on:
|
||||||
|
# - mysql_v2
|
||||||
|
# restart: always
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: borrow-system_mysql
|
||||||
|
image: mysql:8.0
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
|
||||||
|
MYSQL_DATABASE: borrow_system_new
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
- mysql-v2-data:/var/lib/mysql
|
||||||
|
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
||||||
|
ports:
|
||||||
|
- "3310:3306"
|
||||||
|
|
||||||
|
no-as-a-service:
|
||||||
|
container_name: borrow-system_naas
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
build:
|
||||||
|
context: ./no-as-a-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
mysql-v2-data:
|
||||||
+43
-35
@@ -1,51 +1,59 @@
|
|||||||
services:
|
services:
|
||||||
# usr-frontend_v2:
|
usr-frontend:
|
||||||
# container_name: borrow_system-usr-frontend
|
container_name: borrow-system_usr-frontend
|
||||||
# build: ./FrontendV2
|
build:
|
||||||
# ports:
|
context: .
|
||||||
# - "8001:80"
|
dockerfile: frontend/Dockerfile
|
||||||
# restart: always
|
args:
|
||||||
|
VITE_BACKEND_URL: ${BACKEND_URL}
|
||||||
# admin-frontend:
|
|
||||||
# container_name: borrow_system-admin-frontend
|
|
||||||
# build: ./admin
|
|
||||||
# ports:
|
|
||||||
# - "8003:80"
|
|
||||||
# restart: always
|
|
||||||
|
|
||||||
backend_v2:
|
|
||||||
container_name: borrow_system-backend_v2
|
|
||||||
build: ./backendV2
|
|
||||||
ports:
|
|
||||||
- "8004:8004"
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
DB_HOST: mysql_v2
|
|
||||||
DB_USER: root
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD_V2}
|
|
||||||
DB_NAME: borrow_system_new
|
|
||||||
depends_on:
|
|
||||||
- mysql_v2
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
mysql_v2:
|
admin-frontend:
|
||||||
container_name: borrow_system-mysql-v2
|
container_name: borrow-system_admin-frontend
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: admin/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_BACKEND_URL: ${BACKEND_URL}
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: borrow-system_backend
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DB_HOST: borrow-system_mysql
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: borrow_system_new
|
||||||
|
MAIL_HOST: ${MAIL_HOST}
|
||||||
|
MAIL_PORT: ${MAIL_PORT}
|
||||||
|
MAIL_USER: ${MAIL_USER}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||||
|
MAIL_SENDEES_CONTACT: ${MAIL_SENDEES_CONTACT}
|
||||||
|
MAIL_SENDEES: ${MAIL_SENDEES}
|
||||||
|
NAAS_URL: ${NAAS_URL}
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: borrow-system_mysql
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
|
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||||
MYSQL_DATABASE: borrow_system_new
|
MYSQL_DATABASE: borrow_system_new
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-v2-data:/var/lib/mysql
|
- mysql-v2-data:/var/lib/mysql
|
||||||
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
||||||
ports:
|
|
||||||
- "3310:3306"
|
|
||||||
|
|
||||||
no-as-a-service:
|
no-as-a-service:
|
||||||
container_name: borrow_system-naas
|
container_name: borrow-system_naas
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
build:
|
build:
|
||||||
context: ./no-as-a-service
|
context: ./no-as-a-service
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Build context is the repo root (npm workspaces share one root package-lock.json)
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY frontend/package.json frontend/
|
||||||
|
COPY admin/package.json admin/
|
||||||
|
COPY backend/package.json backend/
|
||||||
|
RUN npm ci -w frontend
|
||||||
|
|
||||||
|
ARG VITE_BACKEND_URL
|
||||||
|
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
|
||||||
|
|
||||||
|
COPY frontend/ frontend/
|
||||||
|
RUN npm run build -w frontend
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
COPY --from=builder /app/frontend/dist .
|
||||||
|
|
||||||
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -14,7 +14,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /backend/ {
|
location /backend/ {
|
||||||
proxy_pass http://borrow_system-backend_v2:8004/;
|
proxy_pass http://borrow-system_backend:8004/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "v2.1.2 (dev)",
|
"version": "2.1.2-dev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"prettier": "^3.9.10",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.0",
|
"typescript-eslint": "^8.39.0",
|
||||||
"vite": "^7.1.0",
|
"vite": "^7.1.0",
|
||||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
import { Alert, Stack, VStack, Spinner, Text, Heading } from "@chakra-ui/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { API_BASE } from "@/config/api.config";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const DeactivatedServices = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [deactivatedServices, setDeactivatedServices] = useState<
|
||||||
|
{ function_name: string }[]
|
||||||
|
>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDeactivatedServices = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/users/deactivated-services`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setDeactivatedServices(data);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch deactivated services");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching deactivated services:", error);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDeactivatedServices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{deactivatedServices.length >= 1 && (
|
||||||
|
<Stack gap="2">
|
||||||
|
<Heading size={"xl"}>{t("deactivated-services")}</Heading>
|
||||||
|
{isLoading && (
|
||||||
|
<VStack colorPalette="teal">
|
||||||
|
<Spinner color="colorPalette.600" />
|
||||||
|
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
{deactivatedServices.length >= 1 &&
|
||||||
|
deactivatedServices.map((item) => (
|
||||||
|
<Alert.Root key={item.function_name} status="warning">
|
||||||
|
<Alert.Indicator />
|
||||||
|
<Alert.Title>
|
||||||
|
{item.function_name} {t("is-deactivated")}
|
||||||
|
</Alert.Title>
|
||||||
|
</Alert.Root>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -69,6 +69,7 @@ export const Header = () => {
|
|||||||
className="mb-6"
|
className="mb-6"
|
||||||
position="relative"
|
position="relative"
|
||||||
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
|
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
|
||||||
|
marginBottom={1}
|
||||||
>
|
>
|
||||||
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
|
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
|
||||||
<Box
|
<Box
|
||||||
@@ -14,5 +14,5 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Provider>
|
<Provider>
|
||||||
<App />
|
<App />
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Container,
|
Container,
|
||||||
Text,
|
Text,
|
||||||
|
VStack,
|
||||||
|
Spinner,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -21,9 +23,21 @@ interface Alert {
|
|||||||
export const ContactPage = () => {
|
export const ContactPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [alert, setAlert] = useState<Alert | null>(null);
|
const [alert, setAlert] = useState<Alert | null>(null);
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
|
setIsSending(true);
|
||||||
|
if (message.trim() === "") {
|
||||||
|
setAlert({
|
||||||
|
type: "error",
|
||||||
|
headline: t("contactPage_messageErrorHeadline"),
|
||||||
|
text: t("contactPage_messageErrorText2"),
|
||||||
|
});
|
||||||
|
setIsSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Logic to send the message
|
// Logic to send the message
|
||||||
const result = await fetch(`${API_BASE}/api/users/contact`, {
|
const result = await fetch(`${API_BASE}/api/users/contact`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -55,6 +69,7 @@ export const ContactPage = () => {
|
|||||||
text: t("contactPage_errorText"),
|
text: t("contactPage_errorText"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setIsSending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +99,12 @@ export const ContactPage = () => {
|
|||||||
</Alert.Content>
|
</Alert.Content>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
)}
|
)}
|
||||||
|
{isSending && (
|
||||||
|
<VStack colorPalette="teal">
|
||||||
|
<Spinner color="colorPalette.600" />
|
||||||
|
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
|
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -19,6 +19,7 @@ import { createLoan } from "@/utils/Fetcher";
|
|||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { approvalAnimation } from "@/components/dotLottie";
|
import { approvalAnimation } from "@/components/dotLottie";
|
||||||
|
import { DeactivatedServices } from "@/components/DeactivatedServices";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -71,6 +72,7 @@ export const HomePage = () => {
|
|||||||
)}
|
)}
|
||||||
<Container className="px-6 sm:px-8 pt-10">
|
<Container className="px-6 sm:px-8 pt-10">
|
||||||
<Header />
|
<Header />
|
||||||
|
<DeactivatedServices />
|
||||||
{isMsg && (
|
{isMsg && (
|
||||||
<MyAlert
|
<MyAlert
|
||||||
status={msgStatus}
|
status={msgStatus}
|
||||||
@@ -131,12 +133,6 @@ export const HomePage = () => {
|
|||||||
>
|
>
|
||||||
{t("get-borrowable-items")}
|
{t("get-borrowable-items")}
|
||||||
</Button>
|
</Button>
|
||||||
{isLoadingA && (
|
|
||||||
<VStack colorPalette="teal">
|
|
||||||
<Spinner color="colorPalette.600" />
|
|
||||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
{borrowableItems.length > 0 && (
|
{borrowableItems.length > 0 && (
|
||||||
<Table.ScrollArea borderWidth="1px" rounded="md">
|
<Table.ScrollArea borderWidth="1px" rounded="md">
|
||||||
<Table.Root size="sm" stickyHeader>
|
<Table.Root size="sm" stickyHeader>
|
||||||
@@ -189,9 +185,11 @@ export const HomePage = () => {
|
|||||||
)}
|
)}
|
||||||
{selectedItems.length >= 1 && (
|
{selectedItems.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
setIsLoadingA(true);
|
||||||
createLoan(selectedItems, startDate, endDate, note).then(
|
createLoan(selectedItems, startDate, endDate, note).then(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
setIsLoadingA(false);
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
setMsgStatus("error");
|
setMsgStatus("error");
|
||||||
setMsgTitle(response.title || t("error"));
|
setMsgTitle(response.title || t("error"));
|
||||||
@@ -207,12 +205,18 @@ export const HomePage = () => {
|
|||||||
setMsgDescription(t("loan-success"));
|
setMsgDescription(t("loan-success"));
|
||||||
setIsMsg(true);
|
setIsMsg(true);
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{t("create-loan")}
|
{t("create-loan")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isLoadingA && (
|
||||||
|
<VStack colorPalette="teal">
|
||||||
|
<Spinner color="colorPalette.600" />
|
||||||
|
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
@@ -121,10 +121,28 @@ export const MyLoansPage = () => {
|
|||||||
|
|
||||||
const formatDate = (iso: string | null) => {
|
const formatDate = (iso: string | null) => {
|
||||||
if (!iso) return "-";
|
if (!iso) return "-";
|
||||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
const date = new Date(iso);
|
||||||
if (!m) return iso;
|
if (isNaN(date.getTime())) return iso;
|
||||||
const [, y, M, d, h, min] = m;
|
return date.toLocaleString("de-DE", {
|
||||||
return `${d}.${M}.${y} ${h}:${min}`;
|
timeZone: "Europe/Berlin",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateAndTime = (isISO: boolean) => {
|
||||||
|
const date = new Date();
|
||||||
|
|
||||||
|
if (isISO) {
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isISO) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTakeAction = async (loanCode: string) => {
|
const handleTakeAction = async (loanCode: string) => {
|
||||||
@@ -151,7 +169,7 @@ export const MyLoansPage = () => {
|
|||||||
setLoans((prev) =>
|
setLoans((prev) =>
|
||||||
prev.map((loan) =>
|
prev.map((loan) =>
|
||||||
loan.loan_code === loanCode
|
loan.loan_code === loanCode
|
||||||
? { ...loan, take_date: new Date().toISOString() }
|
? { ...loan, take_date: dateAndTime(true) }
|
||||||
: loan,
|
: loan,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -191,7 +209,7 @@ export const MyLoansPage = () => {
|
|||||||
setLoans((prev) =>
|
setLoans((prev) =>
|
||||||
prev.map((loan) =>
|
prev.map((loan) =>
|
||||||
loan.loan_code === loanCode
|
loan.loan_code === loanCode
|
||||||
? { ...loan, returned_date: new Date().toISOString() }
|
? { ...loan, returned_date: dateAndTime(true) }
|
||||||
: loan,
|
: loan,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
+9
-2
@@ -60,7 +60,7 @@
|
|||||||
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
|
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"change-language": "Sprache ändern",
|
"change-language": "Sprache ändern",
|
||||||
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
|
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden. Das gesamte System ist auf die deutsche Zeitzone eingestellt.",
|
||||||
"optional-note": "Optionale Notiz",
|
"optional-note": "Optionale Notiz",
|
||||||
"note": "Notiz",
|
"note": "Notiz",
|
||||||
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
|
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
|
||||||
@@ -94,5 +94,12 @@
|
|||||||
"naas-error": "Fehler mit no-as-a-service",
|
"naas-error": "Fehler mit no-as-a-service",
|
||||||
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
|
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
|
||||||
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
|
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
|
||||||
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde."
|
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde.",
|
||||||
|
"serviceDeactivatedHeadline": "Service deaktiviert",
|
||||||
|
"contactPage_serviceDeactivatedText": "Der Kontaktservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
|
||||||
|
"loan_page_serviceDeactivatedText": "Der Ausleihservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
|
||||||
|
"is-deactivated": "ist deaktiviert.",
|
||||||
|
"deactivated-services": "Deaktivierte Services",
|
||||||
|
"contactPage_messageErrorHeadline": "Fehler bei der Nachrichteneingabe",
|
||||||
|
"contactPage_messageErrorText2": "Bitte geben Sie eine Nachricht ein, bevor Sie sie senden."
|
||||||
}
|
}
|
||||||
+6
-2
@@ -60,7 +60,7 @@
|
|||||||
"sure-delete-loan-2": "It will remain visible to the admin.",
|
"sure-delete-loan-2": "It will remain visible to the admin.",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"change-language": "Change language",
|
"change-language": "Change language",
|
||||||
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
|
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such. The entire system is set to Berlin timezone.",
|
||||||
"optional-note": "Optional note",
|
"optional-note": "Optional note",
|
||||||
"note": "Note",
|
"note": "Note",
|
||||||
"user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
|
"user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
|
||||||
@@ -97,5 +97,9 @@
|
|||||||
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet.",
|
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet.",
|
||||||
"serviceDeactivatedHeadline": "Service deactivated",
|
"serviceDeactivatedHeadline": "Service deactivated",
|
||||||
"contactPage_serviceDeactivatedText": "The contact service is currently deactivated. Please try again later.",
|
"contactPage_serviceDeactivatedText": "The contact service is currently deactivated. Please try again later.",
|
||||||
"loan_page_serviceDeactivatedText": "The loan service is currently deactivated. Please try again later."
|
"loan_page_serviceDeactivatedText": "The loan service is currently deactivated. Please try again later.",
|
||||||
|
"is-deactivated": "is deactivated.",
|
||||||
|
"deactivated-services": "Deactivated services",
|
||||||
|
"contactPage_messageErrorHeadline": "Error submitting message",
|
||||||
|
"contactPage_messageErrorText2": "Please enter a message before sending it."
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[mysqld]
|
||||||
|
default-time-zone = Europe/Berlin
|
||||||
+1
-1
Submodule no-as-a-service updated: 764062a307...e6b4218394
+2984
-1698
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user